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

@ -258,7 +258,7 @@ async fn cancel_terminal_task_is_explicit_error_without_runner_call() {
}
#[tokio::test]
async fn cancel_already_cancelling_is_idempotent_and_retries_runner_cancel() {
async fn cancel_already_cancelling_is_idempotent_without_duplicate_runner_signal() {
let store = Arc::new(FakeStore::default());
let runner = Arc::new(FakeRunner::ok());
let events = Arc::new(SpyEvents::default());
@ -274,7 +274,7 @@ async fn cancel_already_cancelling_is_idempotent_and_retries_runner_cancel() {
.unwrap();
assert_eq!(out.task.state, BackgroundTaskState::Cancelling);
assert_eq!(runner.cancels(), vec![tid(10)]);
assert!(runner.cancels().is_empty());
assert!(store.saved_states().is_empty());
}

View File

@ -16,6 +16,13 @@ fn codes_are_stable() {
assert_eq!(AppError::Process("x".into()).code(), "PROCESS");
assert_eq!(AppError::Git("x".into()).code(), "GIT");
assert_eq!(AppError::Remote("x".into()).code(), "REMOTE");
assert_eq!(
AppError::AgentStopped {
agent_id: domain::AgentId::from_uuid(uuid::Uuid::nil()),
}
.code(),
"AGENT_STOPPED"
);
assert_eq!(AppError::Internal("x".into()).code(), "INTERNAL");
}

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());

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();