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:
2026-08-12 16:02:17 +02:00
parent a3bda8ebc0
commit 803e9de093
22 changed files with 561 additions and 112 deletions

View File

@ -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)]);
}