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

@ -130,6 +130,7 @@ impl AgentSession for FakeSession {
#[derive(Default)]
struct FakeCascade {
calls: Mutex<Vec<(ProjectId, AgentId)>>,
fail: bool,
}
#[derive(Default)]
@ -163,8 +164,19 @@ impl AgentSelfRestartScheduler for FakeRestartScheduler {
#[async_trait]
impl StopAgentExecutionCascade for FakeCascade {
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<(), application::AppError> {
self.calls.lock().unwrap().push((project.id, agent_id));
if self.fail {
Err(application::AppError::Process(
"child cancellation failed".to_owned(),
))
} else {
Ok(())
}
}
}
@ -534,7 +546,7 @@ async fn stop_structured_shuts_down_and_removes_session() {
}
#[tokio::test]
async fn stop_structured_runs_execution_cascade_best_effort() {
async fn stop_structured_runs_execution_cascade_before_shutdown() {
let f = fixture();
let a = aid(10);
let (shutdown_called, cancel_called) = insert_structured(&f.structured, sid(2), a, nid(100));
@ -565,6 +577,33 @@ async fn stop_structured_runs_execution_cascade_best_effort() {
assert!(f.structured.is_empty(), "live session is removed");
}
#[tokio::test]
async fn cascade_failure_is_reported_after_primary_runtime_is_still_stopped() {
let f = fixture();
let a = aid(10);
let (shutdown_called, _) = insert_structured(&f.structured, sid(2), a, nid(100));
let cascade = Arc::new(FakeCascade {
fail: true,
..Default::default()
});
let stop = f
.stop
.with_execution_cascade(cascade as Arc<dyn StopAgentExecutionCascade>);
let error = stop
.execute(StopLiveAgentInput {
project: project(),
agent_id: a,
reason: None,
})
.await
.expect_err("partial cascade must be surfaced");
assert_eq!(error.code(), "PROCESS");
assert!(shutdown_called.load(Ordering::SeqCst));
assert!(f.structured.is_empty());
}
#[tokio::test]
async fn stop_removes_every_runtime_for_the_agent_across_registries() {
let f = fixture();