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

@ -48,6 +48,7 @@ struct RegistryIndex {
loaded: bool,
task_to_project: HashMap<TaskId, ProjectId>,
open_by_agent: HashMap<AgentId, BTreeMap<TaskId, BackgroundTask>>,
children_by_parent: HashMap<TaskId, BTreeMap<TaskId, BackgroundTask>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@ -299,6 +300,16 @@ impl FsBackgroundTaskStore {
for tasks in registry.open_by_agent.values_mut() {
tasks.remove(&task.id);
}
for tasks in registry.children_by_parent.values_mut() {
tasks.remove(&task.id);
}
if let Some(parent_task_id) = task.parent_task_id {
registry
.children_by_parent
.entry(parent_task_id)
.or_default()
.insert(task.id, task.clone());
}
if !task.is_terminal() {
registry
.open_by_agent
@ -315,10 +326,18 @@ impl RegistryIndex {
loaded: true,
task_to_project: HashMap::new(),
open_by_agent: HashMap::new(),
children_by_parent: HashMap::new(),
};
for doc in state.docs.values() {
for task in &doc.tasks {
registry.task_to_project.insert(task.id, task.project_id);
if let Some(parent_task_id) = task.parent_task_id {
registry
.children_by_parent
.entry(parent_task_id)
.or_default()
.insert(task.id, task.clone());
}
if !task.is_terminal() {
registry
.open_by_agent
@ -396,6 +415,21 @@ impl BackgroundTaskStore for FsBackgroundTaskStore {
Ok(tasks)
}
async fn list_children(
&self,
parent_task_id: TaskId,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
self.ensure_registry_loaded().await?;
let registry = self.registry.read().await;
let mut tasks: Vec<BackgroundTask> = registry
.children_by_parent
.get(&parent_task_id)
.map(|tasks| tasks.values().cloned().collect())
.unwrap_or_default();
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
Ok(tasks)
}
async fn list_undelivered_completions(
&self,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {