fix(runtime): cancel en cascade des tasks filles — stop agent / cancel parent (#200, QA verte)
Cancel transitif, idempotent et ownership-aware sur le graphe parent→enfants des background tasks: - domain/background_task + ports: modèle parent/enfant (parent_task_id, rejet du self-parent), index children_by_parent, traversal des descendants en largeur. - application/background + infrastructure/store: cascade cancel (annule les descendants running/open, laisse les terminaux), idempotente face aux double-cancel et ordres de terminaison non déterministes; respect de l'ownership (parent_task_link). - orchestrator/service: stop agent → cascade cancel des tasks lancées par cet agent (relatesTo #196); cancel task parent → cascade de ses sous-tasks (dependsOn #199). Refactor unifiant le chemin de cancel, sans sémantique #196/#199 regreffée (tests hérités verts). - frontend: projection parentTaskId (workstate normalization/domain/services). Tests QA verts: domain background_task, application background_task_cancel (cascades_background_task_subtree, cascades_to_running_child, cascades_to_open_descendants_and_leaves_terminal_child, parent_task_link_roundtrips_and_rejects_self_parent), orchestrator_service, workstate_actions, workstate.test.tsx.
This commit is contained in:
@ -391,6 +391,20 @@ impl BackgroundTaskStore for FakeTaskStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_children(
|
||||
&self,
|
||||
parent_task_id: TaskId,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
Ok(self
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|task| task.parent_task_id == Some(parent_task_id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_undelivered_completions(
|
||||
&self,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
|
||||
@ -43,6 +43,10 @@ fn running_task(id: TaskId) -> BackgroundTask {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn child_task(id: TaskId, parent: TaskId) -> BackgroundTask {
|
||||
running_task(id).with_parent_task(parent).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStore {
|
||||
tasks: Mutex<HashMap<TaskId, BackgroundTask>>,
|
||||
@ -90,6 +94,20 @@ impl BackgroundTaskStore for FakeStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_children(
|
||||
&self,
|
||||
parent_task_id: TaskId,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
Ok(self
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|task| task.parent_task_id == Some(parent_task_id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_undelivered_completions(
|
||||
&self,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
@ -304,3 +322,84 @@ async fn cancel_kill_failure_persists_cancel_failed_and_returns_error() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_parent_cascades_to_open_descendants_and_leaves_terminal_child() {
|
||||
let store = Arc::new(FakeStore::default());
|
||||
let runner = Arc::new(FakeRunner::ok());
|
||||
let events = Arc::new(SpyEvents::default());
|
||||
let parent = running_task(tid(10));
|
||||
let child = child_task(tid(11), tid(10));
|
||||
let grandchild = child_task(tid(12), tid(11));
|
||||
let terminal_child = child_task(tid(13), tid(10))
|
||||
.complete(BackgroundTaskResult::Success {
|
||||
finished_at_ms: 130,
|
||||
exit_code: Some(0),
|
||||
summary: "done".to_owned(),
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
})
|
||||
.unwrap();
|
||||
store.insert(parent);
|
||||
store.insert(child);
|
||||
store.insert(grandchild);
|
||||
store.insert(terminal_child.clone());
|
||||
|
||||
usecase(Arc::clone(&store), Arc::clone(&runner), events)
|
||||
.execute(tid(10))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(runner.cancels(), vec![tid(10), tid(11), tid(12)]);
|
||||
assert_eq!(
|
||||
store.get(tid(13)).await.unwrap().unwrap().state,
|
||||
BackgroundTaskState::Completed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_terminal_parent_still_cascades_to_running_child() {
|
||||
let store = Arc::new(FakeStore::default());
|
||||
let runner = Arc::new(FakeRunner::ok());
|
||||
let events = Arc::new(SpyEvents::default());
|
||||
let parent = running_task(tid(10))
|
||||
.complete(BackgroundTaskResult::Success {
|
||||
finished_at_ms: 130,
|
||||
exit_code: Some(0),
|
||||
summary: "done".to_owned(),
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
})
|
||||
.unwrap();
|
||||
store.insert(parent);
|
||||
store.insert(child_task(tid(11), tid(10)));
|
||||
|
||||
let out = usecase(Arc::clone(&store), Arc::clone(&runner), events)
|
||||
.execute(tid(10))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.task.state, BackgroundTaskState::Completed);
|
||||
assert_eq!(runner.cancels(), vec![tid(11)]);
|
||||
assert_eq!(
|
||||
store.get(tid(11)).await.unwrap().unwrap().state,
|
||||
BackgroundTaskState::Cancelling
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_agent_subtree_visits_each_task_once() {
|
||||
let store = Arc::new(FakeStore::default());
|
||||
let runner = Arc::new(FakeRunner::ok());
|
||||
let events = Arc::new(SpyEvents::default());
|
||||
store.insert(running_task(tid(10)));
|
||||
store.insert(child_task(tid(11), tid(10)));
|
||||
store.insert(child_task(tid(12), tid(11)));
|
||||
|
||||
usecase(Arc::clone(&store), Arc::clone(&runner), events)
|
||||
.cancel_subtree_for_agent(pid(1), aid(2))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(runner.cancels(), vec![tid(10), tid(11), tid(12)]);
|
||||
}
|
||||
|
||||
@ -36,8 +36,8 @@ use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::terminal::{SessionKind, TerminalSession};
|
||||
use domain::{
|
||||
BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, OrchestratorCommand,
|
||||
OrchestratorRequest, PtySize, SessionId, TaskId,
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskState, BackgroundTaskWakePolicy,
|
||||
OrchestratorCommand, OrchestratorRequest, PtySize, SessionId, TaskId,
|
||||
};
|
||||
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use uuid::Uuid;
|
||||
@ -429,6 +429,20 @@ impl BackgroundTaskStore for RecordingBackgroundStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_children(
|
||||
&self,
|
||||
parent_task_id: TaskId,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
Ok(self
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|task| task.parent_task_id == Some(parent_task_id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_undelivered_completions(
|
||||
&self,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
@ -458,11 +472,16 @@ impl BackgroundTaskStore for RecordingBackgroundStore {
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingBackgroundRunner {
|
||||
specs: Arc<Mutex<Vec<BackgroundTaskSpec>>>,
|
||||
cancels: Arc<Mutex<Vec<TaskId>>>,
|
||||
}
|
||||
impl RecordingBackgroundRunner {
|
||||
fn specs(&self) -> Vec<BackgroundTaskSpec> {
|
||||
self.specs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn cancels(&self) -> Vec<TaskId> {
|
||||
self.cancels.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl BackgroundTaskRunner for RecordingBackgroundRunner {
|
||||
@ -475,7 +494,8 @@ impl BackgroundTaskRunner for RecordingBackgroundRunner {
|
||||
Ok(BackgroundTaskHandle { task_id })
|
||||
}
|
||||
|
||||
async fn cancel(&self, _task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
|
||||
async fn cancel(&self, task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
|
||||
self.cancels.lock().unwrap().push(task_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1048,6 +1068,73 @@ async fn stop_agent_stops_structured_session() {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_cascades_background_task_subtree() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let fx = structured_ask_fixture_with_observable_background(FakeContexts::with_agent(
|
||||
&agent,
|
||||
"# persona",
|
||||
));
|
||||
fx.structured.insert_in_project(
|
||||
project_id(),
|
||||
Arc::new(EmptyStructuredSession { id: sid(9801) }),
|
||||
aid(1),
|
||||
nid(700),
|
||||
);
|
||||
let parent_id = TaskId::from_uuid(Uuid::from_u128(9101));
|
||||
let child_id = TaskId::from_uuid(Uuid::from_u128(9102));
|
||||
let parent = BackgroundTask::new(
|
||||
parent_id,
|
||||
project_id(),
|
||||
aid(1),
|
||||
BackgroundTaskKind::Command {
|
||||
label: "parent".to_owned(),
|
||||
},
|
||||
BackgroundTaskWakePolicy::WakeOwner,
|
||||
10,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.transition(BackgroundTaskState::Running, 11)
|
||||
.unwrap();
|
||||
let child = BackgroundTask::new(
|
||||
child_id,
|
||||
project_id(),
|
||||
aid(1),
|
||||
BackgroundTaskKind::Command {
|
||||
label: "child".to_owned(),
|
||||
},
|
||||
BackgroundTaskWakePolicy::WakeOwner,
|
||||
12,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_parent_task(parent_id)
|
||||
.unwrap()
|
||||
.transition(BackgroundTaskState::Running, 13)
|
||||
.unwrap();
|
||||
fx.background_store.create(&parent).await.unwrap();
|
||||
fx.background_store.create(&child).await.unwrap();
|
||||
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
|
||||
)
|
||||
.await
|
||||
.expect("stop ok");
|
||||
|
||||
assert_eq!(fx.background_runner.cancels(), vec![parent_id, child_id]);
|
||||
assert_eq!(
|
||||
fx.background_store.task(parent_id).unwrap().state,
|
||||
BackgroundTaskState::Cancelling
|
||||
);
|
||||
assert_eq!(
|
||||
fx.background_store.task(child_id).unwrap().state,
|
||||
BackgroundTaskState::Cancelling
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_without_live_session_is_not_found() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
@ -4715,6 +4802,11 @@ async fn ask_structured_final_wins_even_with_attached_background_task() {
|
||||
child_task.rendezvous.is_some(),
|
||||
"the child task keeps its rendezvous correlation"
|
||||
);
|
||||
assert_eq!(
|
||||
child_task.parent_task_id,
|
||||
Some(rendezvous_task.id),
|
||||
"the child task is owned by the rendezvous task for subtree cancellation"
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression #201 : si le flux structuré se clôt sans `Final`, l'erreur publique
|
||||
@ -4760,8 +4852,7 @@ async fn ask_structured_session_error_surfaces_process_message_not_no_reply() {
|
||||
project_id(),
|
||||
Arc::new(FailingStructuredSession {
|
||||
id: sid(9802),
|
||||
message: "codex exec exited with code 1: 401 Unauthorized: Missing bearer"
|
||||
.to_owned(),
|
||||
message: "codex exec exited with code 1: 401 Unauthorized: Missing bearer".to_owned(),
|
||||
}),
|
||||
aid(1),
|
||||
nid(700),
|
||||
|
||||
@ -220,6 +220,20 @@ impl BackgroundTaskStore for FakeBackgroundTaskStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_children(
|
||||
&self,
|
||||
parent_task_id: TaskId,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
Ok(self
|
||||
.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|task| task.parent_task_id == Some(parent_task_id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_undelivered_completions(
|
||||
&self,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
|
||||
Reference in New Issue
Block a user