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

@ -472,6 +472,21 @@ impl McpServer {
self.enforce_durable_tool_policy(&policy, &name)?;
}
// Stop closes the entire MCP surface synchronously, including provider
// tools that do not map through OrchestratorCommand. The stop tool itself
// stays admitted so repeated/self stops remain idempotent.
if name != "idea_stop_agent" {
if let Ok(raw) = uuid::Uuid::parse_str(&self.requester) {
if let Err(error) = self
.service
.ensure_agent_execution_active(&self.project, domain::AgentId::from_uuid(raw))
{
self.publish_processed(&name, false);
return Ok(tool_result_text(&error.to_string(), true));
}
}
}
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
let started = Instant::now();

View File

@ -620,6 +620,7 @@ pub fn map_tool_call(
},
"idea_launch_agent" => OrchestratorRequest {
request_type: Some("agent.run".to_owned()),
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
profile: s("profile"),
task: s("task"),
@ -1210,10 +1211,22 @@ mod tests {
profile: None,
context: None,
visibility: OrchestratorVisibility::Background,
requester: None,
}
);
}
#[test]
fn launch_agent_maps_handshake_requester_for_stop_ownership() {
assert!(matches!(
map_tool_call("idea_launch_agent", &json!({ "target": "Dev" }), REQ).unwrap(),
OrchestratorCommand::SpawnAgent {
requester: Some(agent),
..
} if agent == domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap())
));
}
#[test]
fn stop_update_and_skill_map_to_their_commands() {
assert_eq!(