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