fix(orchestrator): la délégation simple vers UX renvoie son Final structuré (#207, QA verte)

Une demande idea_ask_agent vers UX échouait avec « finished without a structured Final response » alors qu'Architect répondait. Deux causes corrigées:

- session/process: un tour CLI terminé en code non nul SANS stdout (ex. erreur 401/auth) renvoyait une sortie vide au lieu du stderr réel; l'orchestrateur concluait alors à un « no Final ». On remonte désormais le stderr effectif (drain sync + async + helper process_exit_message + test dédié).

- orchestrator/service: la branche « ask waiting on background » masquait le premier Final structuré; la réponse synchrone redevient le premier Final (suppression de active_rendezvous_has_background_tasks / mark_rendezvous_task_waiting).

Nettoyage runtime corollaire (#204): arrêt du live runtime stale (PTY + structuré) d'un agent recréé afin que la délégation route vers l'agent courant (LiveSessions, stop_live_agent_runtime_in_project, wiring backend via DeleteAgent::with_runtime_cleanup, lifecycle, workstate cascade).
This commit is contained in:
2026-08-12 14:09:06 +02:00
parent ba176e3cf2
commit 646b9d9284
11 changed files with 673 additions and 203 deletions

View File

@ -6,7 +6,7 @@
//! that turns a request into the *same* calls the UI makes:
//!
//! - `spawn_agent` → [`CreateAgentFromScratch`] (if unknown) then [`LaunchAgent`],
//! - `stop_agent` → resolve the agent's live session, then [`CloseTerminal`],
//! - `stop_agent` → stop the agent's live PTY or structured runtime,
//! - `update_agent_context` → [`UpdateAgentContext`].
//!
//! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]):
@ -53,7 +53,10 @@ use crate::orchestrator::{
WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::terminal::{
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
TerminalSessions,
};
use crate::workstate::{
GetLiveStateLean, StopAgentExecutionCascade, UpdateLiveState, UpdateLiveStateInput,
};
@ -431,9 +434,9 @@ pub struct OrchestratorService {
/// 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)>>,
/// Composite business rendezvous currently driven by a target agent. Used to
/// structurally attach `idea_run_in_background` tasks launched during
/// `idea_ask_agent` without parsing the model's textual `Final`.
/// Headless rendezvous currently driven by a target agent. Used to structurally
/// attach `idea_run_in_background` tasks for observability/cancellation while the
/// synchronous answer remains the first structured `Final`.
active_rendezvous: StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
/// 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
@ -751,19 +754,6 @@ impl OrchestratorService {
}
}
fn active_rendezvous_has_background_tasks(
&self,
agent: RuntimeAgentKey,
rendezvous_id: RendezvousId,
) -> bool {
self.active_rendezvous
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&agent)
.filter(|active| active.link.rendezvous_id == rendezvous_id)
.is_some_and(|active| !active.background_tasks.is_empty())
}
/// 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
@ -893,27 +883,6 @@ impl OrchestratorService {
.await
}
async fn mark_rendezvous_task_waiting(&self, task_id: Option<TaskId>) -> Result<(), AppError> {
let (Some(store), Some(task_id)) = (&self.background_tasks, task_id) else {
return Ok(());
};
let task = store
.get(task_id)
.await
.map_err(|err| AppError::Store(err.to_string()))?
.ok_or_else(|| AppError::Store(format!("background task {task_id} not found")))?;
if task.is_terminal() || task.state == BackgroundTaskState::Waiting {
return Ok(());
}
let waiting = task
.transition(BackgroundTaskState::Waiting, self.now_ms())
.map_err(|err| AppError::Internal(err.to_string()))?;
store
.save(&waiting)
.await
.map_err(|err| AppError::Store(err.to_string()))
}
async fn complete_rendezvous_task_failure(
&self,
project: &Project,
@ -1963,7 +1932,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_silent(agent_key, ticket);
let _pending = input.enqueue_silent(agent_key, ticket);
let rendezvous_link = BackgroundTaskRendezvousLink {
rendezvous_id: RendezvousId::new_random(),
ticket_id,
@ -2071,7 +2040,7 @@ impl OrchestratorService {
};
// Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu.
let initial_result = match self
let result = match self
.run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started)
.await
{
@ -2163,64 +2132,6 @@ impl OrchestratorService {
}
};
let result = if self
.active_rendezvous_has_background_tasks(agent_key, rendezvous_link.rendezvous_id)
{
self.mark_rendezvous_task_waiting(rendezvous_task).await?;
crate::diag!(
"[rendezvous] ask waiting on background: target={target} \
(agent {agent_id}) ticket={ticket_id} rendezvous={} first_final_len={}",
rendezvous_link.rendezvous_id,
initial_result.len(),
);
let remaining = self.ask_ceiling.saturating_sub(started.elapsed());
match tokio::time::timeout(remaining, pending).await {
Ok(Ok(domain::mailbox::TurnResolution::Replied(content))) => content,
Ok(Ok(domain::mailbox::TurnResolution::ReturnedToPromptNoReply)) => {
let err = AppError::TargetReturnedNoReply(target.to_owned());
self.complete_rendezvous_task_failure(
project,
agent_id,
rendezvous_task,
format!("NoReply: {err}"),
)
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
return Err(err);
}
Ok(Err(err)) => {
let err = AppError::Process(err.to_string());
self.complete_rendezvous_task_failure(
project,
agent_id,
rendezvous_task,
format!("NoReply: {err}"),
)
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
return Err(err);
}
Err(_elapsed) => {
self.complete_rendezvous_task_failure(
project,
agent_id,
rendezvous_task,
format!(
"Timeout: composite rendezvous ceiling reached for target {target}"
),
)
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
return Err(AppError::TargetCeilingActive(target.to_owned()));
}
}
} else {
initial_result
};
self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &result)
.await?;
@ -2665,7 +2576,7 @@ impl OrchestratorService {
.as_ref()
.and_then(|p| p.runtime_for(project, agent_id)),
allow_structured_alongside_pty: true,
require_structured: false,
require_structured: true,
})
.await?;
@ -2774,7 +2685,7 @@ impl OrchestratorService {
})
}
/// `stop_agent`: translate the agent name → its live session → `CloseTerminal`.
/// `stop_agent`: translate the agent name → its live runtime → stop it.
async fn stop_agent(
&self,
project: &Project,
@ -2785,14 +2696,19 @@ impl OrchestratorService {
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
let session_id = self
.sessions
.session_for_agent_in_project(project.id, &agent_id)
.ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?;
self.close_terminal
.execute(CloseTerminalInput { session_id })
.await?;
let structured = self
.structured
.clone()
.unwrap_or_else(|| Arc::new(StructuredSessions::new()));
let live = LiveSessions::new(Arc::clone(&self.sessions), structured);
if stop_live_agent_runtime_in_project(project, agent_id, &live, &self.close_terminal)
.await?
.is_empty()
{
return Err(AppError::NotFound(format!(
"running session for agent {name}"
)));
}
Ok(OrchestratorOutcome {
detail: format!("stopped agent {name}"),