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