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:
@ -43,7 +43,10 @@ use crate::model_server::{
|
||||
};
|
||||
use crate::project::project_context_path;
|
||||
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
|
||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||
use crate::terminal::{
|
||||
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
|
||||
TerminalSessions,
|
||||
};
|
||||
use crate::workstate::GetLiveStateLean;
|
||||
|
||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||
@ -1033,13 +1036,29 @@ pub struct DeleteAgentInput {
|
||||
pub struct DeleteAgent {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
runtime_cleanup: Option<(Arc<LiveSessions>, Arc<CloseTerminal>)>,
|
||||
}
|
||||
|
||||
impl DeleteAgent {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { contexts, events }
|
||||
Self {
|
||||
contexts,
|
||||
events,
|
||||
runtime_cleanup: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires live-runtime cleanup performed before the manifest entry is deleted.
|
||||
#[must_use]
|
||||
pub fn with_runtime_cleanup(
|
||||
mut self,
|
||||
live: Arc<LiveSessions>,
|
||||
close: Arc<CloseTerminal>,
|
||||
) -> Self {
|
||||
self.runtime_cleanup = Some((live, close));
|
||||
self
|
||||
}
|
||||
|
||||
/// Drops the manifest entry for the agent.
|
||||
@ -1058,6 +1077,11 @@ impl DeleteAgent {
|
||||
if entries.len() == before {
|
||||
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
||||
}
|
||||
if let Some((live, close)) = &self.runtime_cleanup {
|
||||
let _stopped =
|
||||
stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close)
|
||||
.await?;
|
||||
}
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
|
||||
@ -73,15 +73,14 @@ pub enum AppError {
|
||||
node_id: NodeId,
|
||||
},
|
||||
|
||||
/// The delegation target finished its turn and **returned to its prompt without
|
||||
/// calling `idea_reply`**, so the rendezvous could produce no answer. Surfaced
|
||||
/// promptly (after a short grace window) instead of blocking the caller until the
|
||||
/// long safety timeout. **Retryable**: the caller may re-ask, ideally reminding the
|
||||
/// target to render its result via `idea_reply` (never plain text). Carries the
|
||||
/// target's display name.
|
||||
/// The delegation target finished its structured/headless turn without producing
|
||||
/// a [`ReplyEvent::Final`](domain::ports::ReplyEvent::Final), so the rendezvous
|
||||
/// could not capture an inline answer. Surfaced promptly instead of blocking the
|
||||
/// caller until the long safety timeout. **Retryable**: the caller may re-ask for a
|
||||
/// concise final response. Carries the target's display name.
|
||||
#[error(
|
||||
"agent {0} returned to its prompt without calling idea_reply (no answer was \
|
||||
rendered); retry the request and ensure the agent replies via idea_reply"
|
||||
"agent {0} finished without a structured Final response (no inline answer was \
|
||||
captured); retry the request and ask for a concise final response"
|
||||
)]
|
||||
TargetReturnedNoReply(String),
|
||||
|
||||
|
||||
@ -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}"),
|
||||
|
||||
@ -33,6 +33,7 @@ pub use registry::{
|
||||
TerminalSessions,
|
||||
};
|
||||
pub use usecases::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
|
||||
stop_live_agent_runtime_in_project, CloseTerminal, CloseTerminalInput, CloseTerminalOutput,
|
||||
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
|
||||
StoppedAgentRuntime, WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
|
||||
@ -4,13 +4,13 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, PtyPort, SpawnSpec};
|
||||
use domain::{
|
||||
DomainEvent, NodeId, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus,
|
||||
TerminalSession,
|
||||
AgentId, DomainEvent, NodeId, Project, ProjectPath, PtySize, SessionId, SessionKind,
|
||||
SessionStatus, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::registry::TerminalSessions;
|
||||
use super::registry::{LiveSessionKind, LiveSessions, TerminalSessions};
|
||||
|
||||
/// Input for [`OpenTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -230,6 +230,59 @@ impl CloseTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime session stopped for one agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StoppedAgentRuntime {
|
||||
/// Agent whose live runtime was stopped.
|
||||
pub agent_id: AgentId,
|
||||
/// Live session id removed from its registry.
|
||||
pub session_id: SessionId,
|
||||
/// Runtime registry that owned the session.
|
||||
pub kind: LiveSessionKind,
|
||||
}
|
||||
|
||||
/// Stops one agent's live runtime across both registries.
|
||||
///
|
||||
/// PTY sessions are closed through [`CloseTerminal`]. Structured sessions are
|
||||
/// removed from their registry before `shutdown()` so relaunch guards stop seeing
|
||||
/// them as live even while shutdown runs.
|
||||
pub async fn stop_live_agent_runtime_in_project(
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
live: &LiveSessions,
|
||||
close: &CloseTerminal,
|
||||
) -> Result<Vec<StoppedAgentRuntime>, AppError> {
|
||||
let mut stopped = Vec::new();
|
||||
|
||||
if let Some(session_id) = live
|
||||
.structured
|
||||
.session_id_for_agent_in_project(project.id, &agent_id)
|
||||
{
|
||||
if let Some(session) = live.structured.remove(&session_id) {
|
||||
session
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||
}
|
||||
stopped.push(StoppedAgentRuntime {
|
||||
agent_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Structured,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(session_id) = live.pty.session_for_agent_in_project(project.id, &agent_id) {
|
||||
close.execute(CloseTerminalInput { session_id }).await?;
|
||||
stopped.push(StoppedAgentRuntime {
|
||||
agent_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(stopped)
|
||||
}
|
||||
|
||||
/// The platform default interactive shell.
|
||||
///
|
||||
/// Resolution policy lives in the application layer (a metier default), not the
|
||||
|
||||
@ -15,7 +15,9 @@ use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::OrchestratorService;
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, LiveSessionKind, LiveSessions};
|
||||
use crate::terminal::{
|
||||
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessionKind, LiveSessions,
|
||||
};
|
||||
|
||||
/// Input for [`AttachLiveAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@ -223,42 +225,15 @@ impl StopLiveAgent {
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<StopLiveAgentOutput, AppError> {
|
||||
// PTY first: delegate to the existing close primitive (removes + kills).
|
||||
if let Some(session_id) = self
|
||||
.live
|
||||
.pty
|
||||
.session_for_agent_in_project(project.id, &agent_id)
|
||||
{
|
||||
self.close
|
||||
.execute(CloseTerminalInput { session_id })
|
||||
.await?;
|
||||
return Ok(StopLiveAgentOutput {
|
||||
agent_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
});
|
||||
}
|
||||
// Structured: remove from the registry first (so the uniqueness guard no
|
||||
// longer sees a live session), then shut the session down out of the lock.
|
||||
if let Some(session_id) = self
|
||||
.live
|
||||
.structured
|
||||
.session_id_for_agent_in_project(project.id, &agent_id)
|
||||
{
|
||||
if let Some(session) = self.live.structured.remove(&session_id) {
|
||||
session
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||
}
|
||||
return Ok(StopLiveAgentOutput {
|
||||
agent_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Structured,
|
||||
});
|
||||
}
|
||||
Err(AppError::NotFound(format!(
|
||||
"running session for agent {agent_id}"
|
||||
)))
|
||||
stop_live_agent_runtime_in_project(project, agent_id, &self.live, &self.close)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|stopped| StopLiveAgentOutput {
|
||||
agent_id: stopped.agent_id,
|
||||
session_id: stopped.session_id,
|
||||
kind: stopped.kind,
|
||||
})
|
||||
.ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user