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:
@ -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> {
|
||||
|
||||
@ -336,6 +336,20 @@ impl BackgroundTaskStore for RecordingStore {
|
||||
.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> {
|
||||
|
||||
@ -130,6 +130,7 @@ fn spawn_spec(task_id: TaskId, deadline_ms: Option<u64>) -> domain::ports::Backg
|
||||
kind: BackgroundTaskKind::Command {
|
||||
label: "test command".to_owned(),
|
||||
},
|
||||
parent_task_id: None,
|
||||
wake_policy: BackgroundTaskWakePolicy::RecordOnly,
|
||||
command: Some(SpawnSpec {
|
||||
command: "/bin/sh".to_owned(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -377,6 +377,20 @@ impl BackgroundTaskStore for InMemoryBackgroundTaskStore {
|
||||
.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> {
|
||||
|
||||
Reference in New Issue
Block a user