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:
@ -13,6 +13,7 @@
|
||||
//! except on a *spawn failure*, where they mark the just-created task `Failed` so
|
||||
//! it never dangles in `Running` with no worker behind it.
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
@ -55,6 +56,8 @@ pub struct SpawnBackgroundCommandInput {
|
||||
pub wake_policy: BackgroundTaskWakePolicy,
|
||||
/// Optional composite rendezvous this task participates in.
|
||||
pub rendezvous: Option<BackgroundTaskRendezvousLink>,
|
||||
/// Optional parent background task. Used for deterministic cancel cascades.
|
||||
pub parent_task_id: Option<TaskId>,
|
||||
/// Optional absolute deadline, epoch milliseconds.
|
||||
pub deadline_ms: Option<u64>,
|
||||
}
|
||||
@ -116,6 +119,7 @@ impl SpawnBackgroundCommand {
|
||||
input.command,
|
||||
input.wake_policy,
|
||||
input.rendezvous,
|
||||
input.parent_task_id,
|
||||
input.deadline_ms,
|
||||
)
|
||||
.await?;
|
||||
@ -131,6 +135,7 @@ impl SpawnBackgroundCommand {
|
||||
command: SpawnSpec,
|
||||
wake_policy: BackgroundTaskWakePolicy,
|
||||
rendezvous: Option<BackgroundTaskRendezvousLink>,
|
||||
parent_task_id: Option<TaskId>,
|
||||
deadline_ms: Option<u64>,
|
||||
) -> Result<BackgroundTask, AppError> {
|
||||
let task_id = TaskId::from_uuid(self.ids.new_uuid());
|
||||
@ -149,6 +154,11 @@ impl SpawnBackgroundCommand {
|
||||
if let Some(link) = rendezvous {
|
||||
task = task.with_rendezvous(link);
|
||||
}
|
||||
if let Some(parent_task_id) = parent_task_id {
|
||||
task = task
|
||||
.with_parent_task(parent_task_id)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
}
|
||||
self.store.create(&task).await.map_err(map_port_err)?;
|
||||
|
||||
let running = task
|
||||
@ -174,6 +184,7 @@ impl SpawnBackgroundCommand {
|
||||
project_id,
|
||||
owner_agent_id,
|
||||
kind: running.kind.clone(),
|
||||
parent_task_id: running.parent_task_id,
|
||||
wake_policy,
|
||||
command: Some(command),
|
||||
deadline_ms,
|
||||
@ -244,23 +255,101 @@ impl CancelBackgroundTask {
|
||||
self
|
||||
}
|
||||
|
||||
/// Projects `Cancelling` immediately, then signals the runner to cancel `task_id`.
|
||||
/// Cancels `task_id` and its open descendants.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] if the task is missing, terminal, or the runner/store fails.
|
||||
/// [`AppError`] if the task is missing, terminal without open descendants, or a port fails.
|
||||
pub async fn execute(&self, task_id: TaskId) -> Result<CancelBackgroundTaskOutput, AppError> {
|
||||
let task = self
|
||||
let root = self
|
||||
.store
|
||||
.get(task_id)
|
||||
.await
|
||||
.map_err(map_port_err)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("background task {task_id}")))?;
|
||||
if task.is_terminal() {
|
||||
|
||||
let tasks = self.cancel_subtree_from_task(root.clone()).await?;
|
||||
let task = tasks
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
.cloned()
|
||||
.unwrap_or(root);
|
||||
Ok(CancelBackgroundTaskOutput { task })
|
||||
}
|
||||
|
||||
/// Cancels all open tasks rooted at one agent.
|
||||
///
|
||||
/// Terminal tasks are never modified. Errors are propagated so command
|
||||
/// handlers do not report a silent success when the runner cannot stop a child.
|
||||
pub async fn cancel_subtree_for_agent(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
) -> Result<Vec<BackgroundTask>, AppError> {
|
||||
let mut roots = self
|
||||
.store
|
||||
.list_open_for_agent(agent_id)
|
||||
.await
|
||||
.map_err(map_port_err)?;
|
||||
roots.retain(|task| task.project_id == project_id);
|
||||
roots.sort_by_key(|task| (task.created_at_ms, task.id));
|
||||
self.cancel_subtree(roots).await
|
||||
}
|
||||
|
||||
async fn cancel_subtree_from_task(
|
||||
&self,
|
||||
root: BackgroundTask,
|
||||
) -> Result<Vec<BackgroundTask>, AppError> {
|
||||
let mut roots = if root.is_terminal() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![root.clone()]
|
||||
};
|
||||
roots.extend(
|
||||
self.store
|
||||
.list_children(root.id)
|
||||
.await
|
||||
.map_err(map_port_err)?,
|
||||
);
|
||||
if roots.is_empty() {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"background task {task_id} is already terminal ({:?})",
|
||||
task.state
|
||||
"background task {} is already terminal ({:?})",
|
||||
root.id, root.state
|
||||
)));
|
||||
}
|
||||
self.cancel_subtree(roots).await
|
||||
}
|
||||
|
||||
async fn cancel_subtree(
|
||||
&self,
|
||||
roots: Vec<BackgroundTask>,
|
||||
) -> Result<Vec<BackgroundTask>, AppError> {
|
||||
let mut queue: VecDeque<BackgroundTask> = roots.into();
|
||||
let mut visited = HashSet::new();
|
||||
let mut cancelled = Vec::new();
|
||||
|
||||
while let Some(task) = queue.pop_front() {
|
||||
if !visited.insert(task.id) {
|
||||
continue;
|
||||
}
|
||||
let mut children = self
|
||||
.store
|
||||
.list_children(task.id)
|
||||
.await
|
||||
.map_err(map_port_err)?;
|
||||
children.sort_by_key(|task| (task.created_at_ms, task.id));
|
||||
queue.extend(children);
|
||||
if task.is_terminal() {
|
||||
continue;
|
||||
}
|
||||
cancelled.push(self.cancel_one(task).await?);
|
||||
}
|
||||
|
||||
cancelled.sort_by_key(|task| (task.created_at_ms, task.id));
|
||||
Ok(cancelled)
|
||||
}
|
||||
|
||||
async fn cancel_one(&self, task: BackgroundTask) -> Result<BackgroundTask, AppError> {
|
||||
let task_id = task.id;
|
||||
|
||||
let cancelling = if task.state == BackgroundTaskState::Cancelling {
|
||||
task
|
||||
@ -287,13 +376,12 @@ impl CancelBackgroundTask {
|
||||
return Err(map_port_err(err));
|
||||
}
|
||||
|
||||
let task = self
|
||||
Ok(self
|
||||
.store
|
||||
.get(task_id)
|
||||
.await
|
||||
.map_err(map_port_err)?
|
||||
.unwrap_or(cancelling);
|
||||
Ok(CancelBackgroundTaskOutput { task })
|
||||
.unwrap_or(cancelling))
|
||||
}
|
||||
|
||||
fn publish_state_changed(&self, task: &BackgroundTask) {
|
||||
@ -396,6 +484,7 @@ impl RetryBackgroundTask {
|
||||
old.wake_policy,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(SpawnBackgroundCommandOutput { task })
|
||||
|
||||
Reference in New Issue
Block a user