fix(custom-cli): le bouton Stop annule le tour courant sans fermer la session et cascade l'annulation aux agents délégués — stop_agent suit l'arbre des dépendances actives (cancel_current_turn + préemption runtime + enfants d'exécution), TurnCancel tue le process du tour en cours, autorisation requester/sous-arbre sur idea_stop_agent, fixtures DTO runtime_state/node_id alignées — #257 (QA verte)

This commit is contained in:
2026-08-28 22:57:32 +02:00
parent 9505a3305d
commit 1aabc3d702
12 changed files with 534 additions and 110 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` → stop the agent's live PTY or structured runtime,
//! - `stop_agent` → stop the agent's live PTY or cancel its structured turn,
//! - `update_agent_context` → [`UpdateAgentContext`].
//!
//! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]):
@ -1224,7 +1224,9 @@ impl OrchestratorService {
result,
} => self.reply(project, from, ticket, result),
OrchestratorCommand::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::StopAgent { name, requester } => {
self.stop_agent(project, name, requester).await
}
OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).await
}
@ -2713,23 +2715,65 @@ impl OrchestratorService {
})
}
/// `stop_agent`: translate the agent name → its live runtime → stop it.
/// `stop_agent`: translate the agent name → its live runtime → stop/cancel it.
async fn stop_agent(
&self,
project: &Project,
name: String,
requester: Option<AgentId>,
) -> Result<OrchestratorOutcome, AppError> {
let agent_id = self
.find_agent_id_by_name(project, &name)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
self.stop_agent_execution_children(project, agent_id).await;
if let Some(requester) = requester {
let allowed = requester == agent_id
|| self
.active_wait_dependencies(requester)
.into_iter()
.any(|dep| dep == agent_id);
if !allowed {
return Err(AppError::Invalid(format!(
"agent {requester} cannot stop agent {agent_id} outside its active subtree"
)));
}
}
let structured = self
.structured
.clone()
.unwrap_or_else(|| Arc::new(StructuredSessions::new()));
let dependencies = self.active_wait_dependencies(agent_id);
for dep in &dependencies {
self.stop_agent_execution_children(project, *dep).await;
if let Some(input) = &self.input {
input.preempt(runtime_key(project, *dep));
}
if let Some(session) = structured.session_for_agent_in_project(project.id, dep) {
session
.cancel_current_turn()
.await
.map_err(|err| AppError::Process(err.to_string()))?;
}
}
self.stop_agent_execution_children(project, agent_id).await;
if let Some(input) = &self.input {
input.preempt(runtime_key(project, agent_id));
}
if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) {
session
.cancel_current_turn()
.await
.map_err(|err| AppError::Process(err.to_string()))?;
return Ok(OrchestratorOutcome {
detail: format!("cancelled current work for agent {name}"),
reply: None,
});
}
let live = LiveSessions::new(Arc::clone(&self.sessions), structured);
if stop_live_agent_runtime_in_project(project, agent_id, &live, &self.close_terminal)
.await?

View File

@ -3,8 +3,8 @@
//! Companion to the read-only work-state model: these use cases let the UI drive
//! an **already-running** agent's live session by **agent id**, never by spawning a
//! new process. [`AttachLiveAgent`] rebinds the hosting cell ("a cell is a view");
//! [`StopLiveAgent`] tears the live session down polymorphically (PTY kill vs
//! structured shutdown). Neither touches the agent entity, its tickets, the
//! [`StopLiveAgent`] tears a PTY live session down or cancels the current structured
//! turn while preserving the structured session. Neither touches the agent entity, its tickets, the
//! conversation summaries, the handoff or the provider sessions.
use std::sync::Arc;
@ -136,14 +136,13 @@ pub trait StopAgentExecutionCascade: Send + Sync {
async fn stop_agent_execution_children(&self, project: &Project, agent_id: AgentId);
}
/// Stops an already-running agent's live session, polymorphically.
/// Stops an already-running agent's current work.
///
/// Resolves the session by agent (PTY first, then structured). A PTY session is
/// torn down through the existing [`CloseTerminal`] primitive (registry removal +
/// process kill); a structured session is removed then `shutdown()`. The agent
/// entity, its tickets, conversation summaries and handoff are **not** removed. Any
/// exit event is left to the existing infrastructure (the PTY pump / session
/// backend) — no bus is invented here.
/// Resolves the session by agent (structured first, then PTY). A structured
/// session keeps its registry entry and receives `cancel_current_turn()`, so the
/// provider conversation/context survives the stop. A PTY session has no finer
/// turn boundary, so it is torn down through the existing [`CloseTerminal`]
/// primitive (registry removal + process kill).
pub struct StopLiveAgent {
live: Arc<LiveSessions>,
close: Arc<CloseTerminal>,
@ -188,7 +187,7 @@ impl StopLiveAgent {
self
}
/// Tears down the agent's live session.
/// Stops the agent's current work.
///
/// # Errors
/// - [`AppError::NotFound`] when the agent has no live session,
@ -197,22 +196,33 @@ impl StopLiveAgent {
&self,
input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> {
self.stop_dependencies(&input.project, input.agent_id).await;
self.stop_dependencies(&input.project, input.agent_id)
.await?;
if let Some(mediator) = &self.input {
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
}
if let Some(out) = self
.cancel_structured_turn_in_project(&input.project, input.agent_id)
.await?
{
return Ok(out);
}
self.stop_one_in_project(&input.project, input.agent_id)
.await
}
async fn stop_dependencies(&self, project: &Project, agent_id: AgentId) {
async fn stop_dependencies(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<(), AppError> {
if let Some(cascade) = &self.cascade {
cascade
.stop_agent_execution_children(project, agent_id)
.await;
}
let Some(waits) = &self.waits else {
return;
return Ok(());
};
for dep in waits.active_wait_dependencies(agent_id) {
if let Some(cascade) = &self.cascade {
@ -221,8 +231,52 @@ impl StopLiveAgent {
if let Some(mediator) = &self.input {
mediator.preempt(RuntimeAgentKey::new(project.id, dep));
}
let _ = self.stop_one_in_project(project, dep).await;
if self
.cancel_structured_turn_in_project(project, dep)
.await?
.is_none()
{
let _ = self.stop_one_in_project(project, dep).await;
}
}
Ok(())
}
async fn cancel_structured_turn_in_project(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<StopLiveAgentOutput>, AppError> {
let Some(snapshot) = self
.live
.live_agent_snapshots_for(project.id, &agent_id)
.into_iter()
.find(|snapshot| snapshot.kind == LiveSessionKind::Structured)
else {
return Ok(None);
};
let Some(session) = self
.live
.structured
.session_for_agent_in_project(project.id, &agent_id)
else {
return Ok(None);
};
self.live
.mark_runtime_state(snapshot, LiveRuntimeState::Stopping);
session
.cancel_current_turn()
.await
.map_err(|err| AppError::Process(err.to_string()))?;
self.live
.mark_runtime_state(snapshot, LiveRuntimeState::Stopped);
Ok(Some(StopLiveAgentOutput {
agent_id: snapshot.agent_id,
session_id: snapshot.session_id,
node_id: snapshot.node_id,
kind: snapshot.kind,
runtime_state: LiveRuntimeState::Stopped,
}))
}
async fn stop_one_in_project(