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:
@ -3638,6 +3638,9 @@ impl From<AgentTicketState> for AgentTicketStateDto {
|
||||
pub struct AgentBackgroundTaskStateDto {
|
||||
/// Stable task id (UUID string).
|
||||
pub task_id: String,
|
||||
/// Optional parent background task id (UUID string).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_task_id: Option<String>,
|
||||
/// Kind discriminant.
|
||||
pub kind: String,
|
||||
/// Lifecycle state.
|
||||
@ -3673,6 +3676,7 @@ impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
|
||||
fn from(task: AgentBackgroundTaskState) -> Self {
|
||||
Self {
|
||||
task_id: task.task_id.to_string(),
|
||||
parent_task_id: task.parent_task_id.map(|id| id.to_string()),
|
||||
kind: background_kind_label_from_work_state(task.kind).to_owned(),
|
||||
state: background_state_label(task.state).to_owned(),
|
||||
exit_code: task.exit_code,
|
||||
@ -5102,6 +5106,9 @@ pub struct BackgroundTaskDto {
|
||||
pub owner_agent_id: String,
|
||||
/// Owning project id (UUID string).
|
||||
pub project_id: String,
|
||||
/// Optional parent background task id (UUID string).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_task_id: Option<String>,
|
||||
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
|
||||
/// `"maintenance"`).
|
||||
pub kind: String,
|
||||
@ -5212,6 +5219,7 @@ impl From<BackgroundTask> for BackgroundTaskDto {
|
||||
task_id: task.id.to_string(),
|
||||
owner_agent_id: task.owner_agent_id.to_string(),
|
||||
project_id: task.project_id.to_string(),
|
||||
parent_task_id: task.parent_task_id.map(|id| id.to_string()),
|
||||
kind: background_kind_label(&task.kind).to_owned(),
|
||||
state: background_state_label(task.state).to_owned(),
|
||||
exit_code,
|
||||
@ -5814,6 +5822,7 @@ mod tests {
|
||||
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13));
|
||||
let state = AgentBackgroundTaskState {
|
||||
task_id: TaskId::from_uuid(Uuid::from_u128(14)),
|
||||
parent_task_id: Some(TaskId::from_uuid(Uuid::from_u128(10))),
|
||||
kind: BackgroundTaskKindLabel::HeadlessRendezvous,
|
||||
state: BackgroundTaskState::Completed,
|
||||
exit_code: None,
|
||||
@ -5832,5 +5841,9 @@ mod tests {
|
||||
assert_eq!(json["requesterAgentId"], requester.to_string());
|
||||
assert_eq!(json["targetAgentId"], target.to_string());
|
||||
assert_eq!(json["conversationId"], conversation_id.to_string());
|
||||
assert_eq!(
|
||||
json["parentTaskId"],
|
||||
TaskId::from_uuid(Uuid::from_u128(10)).to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -576,6 +576,34 @@ impl BackgroundTaskStore for AppBackgroundTaskStore {
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
async fn list_children(
|
||||
&self,
|
||||
parent_task_id: TaskId,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
let project_ids = if let Some(project_id) = self.project_for_task(parent_task_id).await? {
|
||||
vec![project_id]
|
||||
} else {
|
||||
self.projects
|
||||
.list_projects()
|
||||
.await
|
||||
.map_err(|err| BackgroundTaskPortError::Store(err.to_string()))?
|
||||
.into_iter()
|
||||
.map(|project| project.id)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for project_id in project_ids {
|
||||
let store = self.store_for_project(project_id).await?;
|
||||
tasks.extend(store.list_children(parent_task_id).await?);
|
||||
}
|
||||
for task in &tasks {
|
||||
self.remember(task.id, task.project_id);
|
||||
}
|
||||
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
async fn list_undelivered_completions(
|
||||
&self,
|
||||
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||
|
||||
Reference in New Issue
Block a user