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

@ -165,6 +165,44 @@ async fn list_open_for_agent_uses_registry_and_filters_terminal_tasks() {
);
}
#[tokio::test]
async fn list_children_uses_parent_index_and_keeps_terminal_tasks_for_traversal() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let parent = running_task(1, 10, 100, 1_000);
let child = running_task(2, 10, 100, 1_000)
.with_parent_task(parent.id)
.unwrap();
let terminal_child = running_task(3, 10, 100, 1_000)
.with_parent_task(parent.id)
.unwrap()
.complete(BackgroundTaskResult::Success {
finished_at_ms: 1_010,
exit_code: Some(0),
summary: "done".to_owned(),
stdout_tail: None,
stderr_tail: None,
})
.unwrap();
let unrelated = running_task(4, 10, 100, 1_000);
store.create(&parent).await.unwrap();
store.create(&child).await.unwrap();
store.create(&terminal_child).await.unwrap();
store.create(&unrelated).await.unwrap();
assert_eq!(
store.list_children(parent.id).await.unwrap(),
vec![child.clone(), terminal_child.clone()]
);
let reborn = FsBackgroundTaskStore::new(&tmp.project_path());
assert_eq!(
reborn.list_children(parent.id).await.unwrap(),
vec![child, terminal_child]
);
}
#[tokio::test]
async fn list_undelivered_completions_and_mark_are_idempotent() {
let tmp = TempDir::new();