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

@ -1065,15 +1065,22 @@ async fn cross_project_cancel_routes_by_agent_id_when_names_collide() {
assert!(cancelled.detail.contains("corr-homonym-cancel"));
assert_eq!(*first_cancelled.lock().unwrap(), 0);
assert_eq!(*second_cancelled.lock().unwrap(), 1);
assert_eq!(*second_cancelled.lock().unwrap(), 0);
assert!(fx
.structured
.session_for_agent_in_project(target_project.id, &first.id)
.is_some());
assert!(fx
.structured
.session_for_agent_in_project(target_project.id, &second.id)
.is_none());
fx.mailbox
.completions()
.push(second.id, TestCompletion::Cancelled);
let _ = timeout(TEST_GUARD, ask)
let error = timeout(TEST_GUARD, ask)
.await
.expect("cancelled ask must settle")
.expect("ask join");
.expect("ask join")
.expect_err("stopped target cannot resolve the rendezvous");
assert_eq!(error.code(), "AGENT_STOPPED");
}
#[tokio::test]
@ -1389,7 +1396,7 @@ async fn stop_agent_kills_the_right_session() {
}
#[tokio::test]
async fn stop_agent_cancels_structured_turn_without_closing_session() {
async fn stop_agent_closes_structured_runtime_and_is_idempotent() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
let contexts = FakeContexts::with_agent(&agent, "# persona");
let fx = ask_fixture(contexts);
@ -1421,18 +1428,29 @@ async fn stop_agent_cancels_structured_turn_without_closing_session() {
fx.pty.kills().is_empty(),
"structured stop must not kill PTY"
);
assert_eq!(*cancel_called.lock().unwrap(), 1);
assert_eq!(*cancel_called.lock().unwrap(), 0);
assert_eq!(
*shutdown_called.lock().unwrap(),
0,
"structured stop must not shutdown the session"
1,
"structured stop must shutdown the runtime"
);
assert!(
fx.structured
.session_for_agent_in_project(project_id(), &agent.id)
.is_some(),
"structured session is preserved for later turns"
.is_none(),
"structured session is removed at the terminal stop boundary"
);
let second = fx
.service
.dispatch(
&project(),
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
)
.await
.expect("repeated stop is an idempotent success");
assert!(second.detail.contains("already stopped"));
assert_eq!(*shutdown_called.lock().unwrap(), 1);
}
#[tokio::test]
@ -1545,19 +1563,61 @@ async fn stop_agent_cascades_background_task_subtree() {
}
#[tokio::test]
async fn stop_agent_without_live_session_is_not_found() {
async fn stop_agent_without_live_session_is_idempotent_success() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
let err = fx
let outcome = fx
.service
.dispatch(
&project(),
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
)
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
.expect("absent runtime is already stopped");
assert!(outcome.detail.contains("already stopped"));
}
#[tokio::test]
async fn stop_cascades_to_agent_launched_by_owner_and_closes_owner_admission() {
let owner = scratch_agent(aid(1), "main", "agents/main.md");
let child = scratch_agent(aid(2), "dev-backend", "agents/dev-backend.md");
let contexts = FakeContexts::with_agent(&owner, "# owner");
contexts.add_agent(&child, "# child");
let fx = fixture(contexts);
fx.service
.dispatch(
&project(),
cmd(&format!(
r#"{{"action":"agent.run","targetAgent":"dev-backend","requestedBy":"{}","visibility":"background"}}"#,
owner.id
)),
)
.await
.expect("owner launches child");
assert!(fx.sessions.session(&sid(777)).is_some());
fx.service
.dispatch(&project(), cmd(r#"{"action":"stop_agent","name":"main"}"#))
.await
.expect("owner stop cascades to launched child");
assert_eq!(fx.pty.kills(), vec![sid(777)]);
assert!(fx.sessions.session(&sid(777)).is_none());
let error = fx
.service
.dispatch(
&project(),
cmd(&format!(
r#"{{"action":"agent.run","targetAgent":"dev-backend","requestedBy":"{}","visibility":"background"}}"#,
owner.id
)),
)
.await
.expect_err("stopped owner cannot launch more work");
assert_eq!(error.code(), "AGENT_STOPPED");
}
#[tokio::test]
@ -5664,7 +5724,7 @@ async fn cross_project_cancel_during_restart_releases_ask_and_correlation() {
.await
.expect("ask task joins")
.expect_err("cancelled restart-aware ask must fail");
assert!(err.to_string().to_ascii_lowercase().contains("cancel"));
assert_eq!(err.code(), "AGENT_STOPPED");
assert_eq!(fx.mailbox.pending(&aid(1)), 0);
}
@ -5687,15 +5747,15 @@ async fn same_project_stop_during_restart_releases_ask_and_leaves_no_replacement
.await
.expect("same-project stop cancels the restart-aware logical turn");
assert_eq!(
stopped.detail,
"cancelled restarting work for agent architect"
assert!(
stopped.detail.contains("stopped agent architect")
|| stopped.detail.contains("already stopped")
);
let err = ask
.await
.expect("ask task joins")
.expect_err("cancelled restart-aware ask must fail");
assert!(err.to_string().to_ascii_lowercase().contains("cancel"));
assert_eq!(err.code(), "AGENT_STOPPED");
assert_eq!(
fx.mediator.busy_state(rkey(1)),
AgentBusyState::Idle,
@ -5705,7 +5765,7 @@ async fn same_project_stop_during_restart_releases_ask_and_leaves_no_replacement
await_until(|| {
fx.live
.entry_for(target.id)
.is_some_and(|entry| entry.status == WorkStatus::Done)
.is_some_and(|entry| entry.status == WorkStatus::Idle)
})
.await;
assert!(
@ -5716,6 +5776,50 @@ async fn same_project_stop_during_restart_releases_ask_and_leaves_no_replacement
);
}
#[tokio::test]
async fn stop_cancels_normal_rendezvous_and_suppresses_late_final_event() {
let target = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&target, "# persona"));
fx.structured.remove(&sid(8_000));
fx.structured.insert_in_project(
project_id(),
Arc::new(TappedDelayedFinalSession { id: sid(9_777) }),
target.id,
nid(700),
);
let service = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { service.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| {
fx.live
.entry_for(target.id)
.is_some_and(|entry| entry.status == WorkStatus::Working)
})
.await;
fx.service
.dispatch(
&project(),
cmd(r#"{"action":"stop_agent","name":"architect"}"#),
)
.await
.expect("stop cancels an ordinary active rendezvous");
let error = ask
.await
.expect("ask task joins")
.expect_err("stopped generation cannot return its delayed Final");
assert_eq!(error.code(), "AGENT_STOPPED");
assert!(!fx.bus.events().iter().any(|event| matches!(
event,
DomainEvent::AgentReplied { agent_id, .. } if *agent_id == target.id
)));
assert!(fx
.live
.entry_for(target.id)
.is_some_and(|entry| { entry.status == WorkStatus::Idle && entry.intent == "Arrêté" }));
}
#[tokio::test]
async fn direct_human_turn_registration_holds_restart_until_replacement_handoff() {
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());