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:
@ -574,6 +574,7 @@ pub struct OrchestratorOutcome {
|
||||
#[derive(Debug, Clone)]
|
||||
struct ActiveRendezvous {
|
||||
link: BackgroundTaskRendezvousLink,
|
||||
task_id: Option<TaskId>,
|
||||
background_tasks: Vec<TaskId>,
|
||||
}
|
||||
|
||||
@ -737,6 +738,14 @@ impl OrchestratorService {
|
||||
.map(|r| r.link.clone())
|
||||
}
|
||||
|
||||
fn active_rendezvous_task_id(&self, agent: RuntimeAgentKey) -> Option<TaskId> {
|
||||
self.active_rendezvous
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&agent)
|
||||
.and_then(|r| r.task_id)
|
||||
}
|
||||
|
||||
fn attach_background_to_active_rendezvous(
|
||||
&self,
|
||||
agent: RuntimeAgentKey,
|
||||
@ -1306,6 +1315,7 @@ impl OrchestratorService {
|
||||
let cwd = resolve_background_cwd(&project.root, cwd)?;
|
||||
let agent_key = runtime_key(project, owner);
|
||||
let rendezvous = self.active_rendezvous_link(agent_key);
|
||||
let parent_task_id = self.active_rendezvous_task_id(agent_key);
|
||||
let output = spawn
|
||||
.execute(SpawnBackgroundCommandInput {
|
||||
project_id: project.id,
|
||||
@ -1321,6 +1331,7 @@ impl OrchestratorService {
|
||||
},
|
||||
wake_policy: BackgroundTaskWakePolicy::WakeOwner,
|
||||
rendezvous: rendezvous.clone(),
|
||||
parent_task_id,
|
||||
deadline_ms,
|
||||
})
|
||||
.await?;
|
||||
@ -1949,7 +1960,7 @@ impl OrchestratorService {
|
||||
.start_rendezvous_task(project, agent_id, rendezvous_link.clone())
|
||||
.await?;
|
||||
let _active_rendezvous =
|
||||
ActiveRendezvousGuard::new(self, agent_key, rendezvous_link.clone());
|
||||
ActiveRendezvousGuard::new(self, agent_key, rendezvous_link.clone(), rendezvous_task);
|
||||
// Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur
|
||||
// cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant
|
||||
// ici (utilisé par le drain plus bas), on le distille directement.
|
||||
@ -2703,6 +2714,8 @@ impl OrchestratorService {
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
|
||||
|
||||
self.stop_agent_execution_children(project, agent_id).await;
|
||||
|
||||
let structured = self
|
||||
.structured
|
||||
.clone()
|
||||
@ -3042,34 +3055,21 @@ impl<'a> WaitEdgeGuard<'a> {
|
||||
#[async_trait::async_trait]
|
||||
impl StopAgentExecutionCascade for OrchestratorService {
|
||||
async fn stop_agent_execution_children(&self, project: &Project, agent_id: AgentId) {
|
||||
let Some(runner) = &self.background_runner else {
|
||||
let (Some(store), Some(runner)) = (&self.background_tasks, &self.background_runner) else {
|
||||
return;
|
||||
};
|
||||
let agent = runtime_key(project, agent_id);
|
||||
stop_active_rendezvous_background_tasks(&self.active_rendezvous, agent, runner.as_ref())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn active_rendezvous_background_tasks(
|
||||
active: &StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
|
||||
agent: RuntimeAgentKey,
|
||||
) -> Vec<TaskId> {
|
||||
active
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&agent)
|
||||
.map(|active| active.background_tasks.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn stop_active_rendezvous_background_tasks(
|
||||
active: &StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
|
||||
agent: RuntimeAgentKey,
|
||||
runner: &dyn BackgroundTaskRunner,
|
||||
) {
|
||||
for task_id in active_rendezvous_background_tasks(active, agent) {
|
||||
let _ = runner.cancel(task_id).await;
|
||||
let Some(clock) = &self.clock else {
|
||||
return;
|
||||
};
|
||||
let mut cancel = crate::background::CancelBackgroundTask::new(
|
||||
Arc::clone(store),
|
||||
Arc::clone(runner),
|
||||
Arc::clone(clock),
|
||||
);
|
||||
if let Some(events) = &self.events {
|
||||
cancel = cancel.with_events(Arc::clone(events));
|
||||
}
|
||||
let _ = cancel.cancel_subtree_for_agent(project.id, agent_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3099,6 +3099,7 @@ impl<'a> ActiveRendezvousGuard<'a> {
|
||||
service: &'a OrchestratorService,
|
||||
agent: RuntimeAgentKey,
|
||||
link: BackgroundTaskRendezvousLink,
|
||||
task_id: Option<TaskId>,
|
||||
) -> Self {
|
||||
let rendezvous_id = link.rendezvous_id;
|
||||
service
|
||||
@ -3109,6 +3110,7 @@ impl<'a> ActiveRendezvousGuard<'a> {
|
||||
agent,
|
||||
ActiveRendezvous {
|
||||
link,
|
||||
task_id,
|
||||
background_tasks: Vec::new(),
|
||||
},
|
||||
);
|
||||
@ -3347,77 +3349,6 @@ mod tests {
|
||||
RuntimeAgentKey::new(domain::ProjectId::from_uuid(uuid::Uuid::nil()), aid(n))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBackgroundRunner {
|
||||
cancelled: TestMutex<Vec<TaskId>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl BackgroundTaskRunner for SpyBackgroundRunner {
|
||||
async fn spawn(
|
||||
&self,
|
||||
spec: domain::ports::BackgroundTaskSpec,
|
||||
) -> Result<domain::ports::BackgroundTaskHandle, domain::ports::BackgroundTaskPortError>
|
||||
{
|
||||
Ok(domain::ports::BackgroundTaskHandle {
|
||||
task_id: spec.task_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel(
|
||||
&self,
|
||||
task_id: TaskId,
|
||||
) -> Result<(), domain::ports::BackgroundTaskPortError> {
|
||||
self.cancelled.lock().unwrap().push(task_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_completions(&self) -> domain::ports::BackgroundCompletionStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_active_rendezvous_background_tasks_cancels_attached_tasks_only() {
|
||||
let active = StdMutex::new(HashMap::new());
|
||||
let target = rkey(2);
|
||||
let unrelated = rkey(9);
|
||||
let first = TaskId::from_uuid(uuid::Uuid::from_u128(101));
|
||||
let second = TaskId::from_uuid(uuid::Uuid::from_u128(102));
|
||||
let other = TaskId::from_uuid(uuid::Uuid::from_u128(999));
|
||||
active.lock().unwrap().insert(
|
||||
target,
|
||||
ActiveRendezvous {
|
||||
link: BackgroundTaskRendezvousLink {
|
||||
rendezvous_id: RendezvousId::from_uuid(uuid::Uuid::from_u128(201)),
|
||||
ticket_id: tid(202),
|
||||
requester_agent_id: Some(aid(1)),
|
||||
target_agent_id: target.agent_id,
|
||||
conversation_id: domain::ConversationId::from_uuid(uuid::Uuid::from_u128(203)),
|
||||
},
|
||||
background_tasks: vec![first, second],
|
||||
},
|
||||
);
|
||||
active.lock().unwrap().insert(
|
||||
unrelated,
|
||||
ActiveRendezvous {
|
||||
link: BackgroundTaskRendezvousLink {
|
||||
rendezvous_id: RendezvousId::from_uuid(uuid::Uuid::from_u128(301)),
|
||||
ticket_id: tid(302),
|
||||
requester_agent_id: Some(aid(1)),
|
||||
target_agent_id: unrelated.agent_id,
|
||||
conversation_id: domain::ConversationId::from_uuid(uuid::Uuid::from_u128(303)),
|
||||
},
|
||||
background_tasks: vec![other],
|
||||
},
|
||||
);
|
||||
let runner = SpyBackgroundRunner::default();
|
||||
|
||||
stop_active_rendezvous_background_tasks(&active, target, &runner).await;
|
||||
|
||||
assert_eq!(*runner.cancelled.lock().unwrap(), vec![first, second]);
|
||||
}
|
||||
|
||||
/// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le
|
||||
/// comportement qui débloque un agent resté `Busy` sur un futur abandonné).
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user