fix(application,domain,infrastructure,web-server,app-tauri,frontend): arrêt d'agent fiable avec cascade sur l'arbre de propriété et statut cohérent — #320 (QA verte)

Symptôme: stopper un agent (ex Main sur UnityPlugin) laissait des travaux
poursuivre, la cascade n'atteignait pas les agents lancés et la pastille
restait orange pendant que l'agent continuait à en appeler d'autres.

- barrière d'admission par génération d'exécution (ExecutionControl): un stop
  ferme l'admission de tout le sous-arbre propriété/attente AVANT toute
  annulation asynchrone — plus aucune nouvelle action inter-agents après stop
- arbre de propriété durable en-process (launched_by) + dépendances d'attente
  actives: la cascade couvre transitivement agents et travaux lancés
- commandes d'un agent arrêté rejetées (AppError::AgentStopped); stop
  lui-même admis pour rester idempotent; réactivation sur relance humaine
  (génération +1) et enregistrement du propriétaire au spawn
- admission exposée aux adaptateurs hors commandes cœur (tickets, templates,
  plugins) via ensure_agent_execution_active; MCP server/tools, web-server et
  commandes Tauri alignés
- cohérence live-state: statut Idle avec intent « Arrêté » ou
  « Arrêt incomplet — Certains travaux sont encore actifs » selon l'issue de
  la cascade; workstate et surfaces UI (AgentsPanel, LayoutGrid,
  ProjectWorkStatePanel) reflètent l'état réel, plus de pastille orange fantôme
- tests: orchestrator_service (cascade/générations/idempotence),
  workstate_actions, background_task_cancel, error_codes, agents, layout,
  workstate

QA verte sur fix/320-agent-stop-cascade. Aucun push.
This commit is contained in:
2026-09-11 16:42:29 +02:00
parent 1dc892d80b
commit 1316e87010
20 changed files with 1048 additions and 154 deletions

View File

@ -229,16 +229,20 @@ impl RestartCellAgentRuntime {
}
}
/// Best-effort stopper for execution resources owned by one live agent turn.
/// Stopper for execution resources owned by one live agent turn.
///
/// Implementations must be idempotent: a missing, already-finished or already-stopped
/// child resource is a successful no-op. Failures are deliberately not propagated by
/// [`StopAgentRuntime`], because the product contract is "stop the agent; cascade related
/// work best-effort".
/// child resource is a successful no-op. Implementations keep cancelling remaining
/// resources after a failure and return the first error so the caller can expose an
/// `Arrêt incomplet` terminal state.
#[async_trait]
pub trait StopAgentExecutionCascade: Send + Sync {
/// Requests cancellation of child execution resources for `agent_id` in `project`.
async fn stop_agent_execution_children(&self, project: &Project, agent_id: AgentId);
async fn stop_agent_execution_children(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<(), AppError>;
}
/// Stops an already-running agent's current work.
@ -316,39 +320,74 @@ impl StopAgentRuntime {
/// Stops the agent's current work.
///
/// # Errors
/// - [`AppError::NotFound`] when the agent has no live session,
/// - [`AppError::Process`] when the PTY kill or structured shutdown fails.
/// Repeated stops are successful. A cancellation or runtime failure is returned
/// only after all remaining resources have still been attempted.
pub async fn execute(
&self,
input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> {
self.stop_dependencies(&input.project, input.agent_id)
.await?;
let (dependencies, dependency_error) =
self.stop_dependencies(&input.project, input.agent_id).await;
if let Some(coordinator) = &self.restart_rendezvous {
coordinator.cancel(RuntimeAgentKey::new(input.project.id, input.agent_id));
}
if let Some(mediator) = &self.input {
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
}
let stopped = stop_live_agent_runtime_in_project(
let runtime_result = stop_live_agent_runtime_in_project(
&input.project,
input.agent_id,
&self.live,
&self.close,
)
.await?;
.await;
let stopped = runtime_result.clone().unwrap_or_default();
if let Some(error) = runtime_result.err().or(dependency_error) {
if let Some(provider) = &self.live_state {
if let Some(update) = provider.live_state_for(&input.project.root) {
for agent_id in dependencies
.iter()
.copied()
.chain(std::iter::once(input.agent_id))
{
let _ = update
.execute(crate::UpdateLiveStateInput {
agent_id,
ticket: None,
intent: "Arrêt incomplet — Certains travaux sont encore actifs."
.to_owned(),
status: WorkStatus::Idle,
progress: None,
last_delegation: None,
})
.await;
}
}
}
return Err(error);
}
if let Some(provider) = &self.live_state {
if let Some(update) = provider.live_state_for(&input.project.root) {
let _ = update
.execute(crate::UpdateLiveStateInput {
agent_id: input.agent_id,
ticket: None,
intent: input.reason.unwrap_or_else(|| "arrêté".to_owned()),
status: WorkStatus::Idle,
progress: None,
last_delegation: None,
})
.await;
for agent_id in dependencies
.iter()
.copied()
.chain(std::iter::once(input.agent_id))
{
let _ = update
.execute(crate::UpdateLiveStateInput {
agent_id,
ticket: None,
intent: if agent_id == input.agent_id {
input.reason.clone().unwrap_or_else(|| "Arrêté".to_owned())
} else {
"Arrêté".to_owned()
},
status: WorkStatus::Idle,
progress: None,
last_delegation: None,
})
.await;
}
}
}
Ok(StopLiveAgentOutput {
@ -362,25 +401,36 @@ impl StopAgentRuntime {
&self,
project: &Project,
agent_id: AgentId,
) -> Result<(), AppError> {
) -> (Vec<AgentId>, Option<AppError>) {
let mut first_error = None;
if let Some(cascade) = &self.cascade {
cascade
if let Err(error) = cascade
.stop_agent_execution_children(project, agent_id)
.await;
.await
{
first_error = Some(error);
}
}
let Some(waits) = &self.waits else {
return Ok(());
return (Vec::new(), first_error);
};
for dep in waits.active_wait_dependencies(agent_id) {
let dependencies = waits.active_wait_dependencies(agent_id);
for dep in dependencies.iter().copied() {
if let Some(cascade) = &self.cascade {
cascade.stop_agent_execution_children(project, dep).await;
if let Err(error) = cascade.stop_agent_execution_children(project, dep).await {
first_error.get_or_insert(error);
}
}
if let Some(mediator) = &self.input {
mediator.preempt(RuntimeAgentKey::new(project.id, dep));
}
let _ = stop_live_agent_runtime_in_project(project, dep, &self.live, &self.close).await;
if let Err(error) =
stop_live_agent_runtime_in_project(project, dep, &self.live, &self.close).await
{
first_error.get_or_insert(error);
}
}
Ok(())
(dependencies, first_error)
}
}