fix(runtime): cancel de background task immédiat et fiable depuis le workstate (#199, QA verte)

Transforme l'annulation d'une background task en contrat immédiat, observable et non-best-effort:

- domain/background_task: états Cancelling/Cancelled/CancelFailed et transitions idempotentes; un kill PTY qui échoue est surfacé en CancelFailed plutôt qu'en succès silencieux.

- infrastructure/runner + store: cancel effectif de la task ciblée, reconcile marque Cancelling même sans live handle, et départage CancelFailed des tâches pending au boot.

- application/background + workstate + orchestrator/wake: chemin de cancel initié depuis le workstate, projection synchronisée de l'état (pas d'attente d'un cycle ambigu).

- frontend: projection workstate de l'état cancelled/cancelling/cancelFailed (cancellingRow/cancelFailedRow/cancelButtons), normalization et services.

Tests QA verts: background_task_cancel, background_task_runner cancel_wins_even_if_output_is_still_available, background_task_store reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending, workstate.test.tsx.
This commit is contained in:
2026-08-12 15:18:38 +02:00
parent f5e2e302ba
commit 46103397de
24 changed files with 699 additions and 76 deletions

View File

@ -209,33 +209,103 @@ impl SpawnBackgroundCommand {
/// Output of [`CancelBackgroundTask`].
#[derive(Debug, Clone)]
pub struct CancelBackgroundTaskOutput {
/// The task as currently persisted, if it still exists. The terminal
/// `Cancelled` state is written by the completion sink, not here.
pub task: Option<BackgroundTask>,
/// The task as currently persisted after the cancel request.
pub task: BackgroundTask,
}
/// Requests cancellation of a running background task.
pub struct CancelBackgroundTask {
store: Arc<dyn BackgroundTaskStore>,
runner: Arc<dyn BackgroundTaskRunner>,
clock: Arc<dyn Clock>,
events: Option<Arc<dyn EventBus>>,
}
impl CancelBackgroundTask {
/// Wires the use case to its ports.
#[must_use]
pub fn new(store: Arc<dyn BackgroundTaskStore>, runner: Arc<dyn BackgroundTaskRunner>) -> Self {
Self { store, runner }
pub fn new(
store: Arc<dyn BackgroundTaskStore>,
runner: Arc<dyn BackgroundTaskRunner>,
clock: Arc<dyn Clock>,
) -> Self {
Self {
store,
runner,
clock,
events: None,
}
}
/// Signals the runner to cancel `task_id` (idempotent).
/// Attaches the public event stream publisher.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
self.events = Some(events);
self
}
/// Projects `Cancelling` immediately, then signals the runner to cancel `task_id`.
///
/// # Errors
/// [`AppError`] if the runner or store fails.
/// [`AppError`] if the task is missing, terminal, or the runner/store fails.
pub async fn execute(&self, task_id: TaskId) -> Result<CancelBackgroundTaskOutput, AppError> {
self.runner.cancel(task_id).await.map_err(map_port_err)?;
let task = self.store.get(task_id).await.map_err(map_port_err)?;
let task = 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() {
return Err(AppError::Invalid(format!(
"background task {task_id} is already terminal ({:?})",
task.state
)));
}
let cancelling = if task.state == BackgroundTaskState::Cancelling {
task
} else {
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
let cancelling = task
.transition(BackgroundTaskState::Cancelling, now)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.save(&cancelling).await.map_err(map_port_err)?;
self.publish_state_changed(&cancelling);
cancelling
};
if let Err(err) = self.runner.cancel(task_id).await {
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
let failed = cancelling
.complete(BackgroundTaskResult::CancelFailed {
finished_at_ms: now,
error: format!("failed to cancel background task: {err}"),
})
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.save(&failed).await.map_err(map_port_err)?;
self.publish_state_changed(&failed);
return Err(map_port_err(err));
}
let task = self
.store
.get(task_id)
.await
.map_err(map_port_err)?
.unwrap_or(cancelling);
Ok(CancelBackgroundTaskOutput { task })
}
fn publish_state_changed(&self, task: &BackgroundTask) {
if let Some(events) = &self.events {
events.publish(DomainEvent::BackgroundTaskStateChanged {
project_id: task.project_id,
task_id: task.id,
owner_agent_id: task.owner_agent_id,
state: task.state,
});
}
}
}
/// Read-only recall of the command invocation a background task was spawned with.

View File

@ -942,12 +942,14 @@ impl OrchestratorService {
owner_agent_id,
rendezvous,
},
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
},
BackgroundTaskResult::Failure { .. } | BackgroundTaskResult::CancelFailed { .. } => {
DomainEvent::BackgroundTaskFailed {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
}
}
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
DomainEvent::BackgroundTaskCancelled {
project_id: project.id,
@ -1333,9 +1335,11 @@ impl OrchestratorService {
BackgroundTaskState::Queued => "Queued",
BackgroundTaskState::Running => "Running",
BackgroundTaskState::Waiting => "Waiting",
BackgroundTaskState::Cancelling => "Cancelling",
BackgroundTaskState::Completed => "Completed",
BackgroundTaskState::Failed => "Failed",
BackgroundTaskState::Cancelled => "Cancelled",
BackgroundTaskState::CancelFailed => "CancelFailed",
BackgroundTaskState::Expired => "Expired",
};
Ok(OrchestratorOutcome {

View File

@ -317,6 +317,10 @@ fn background_completion_prompt(task: &BackgroundTask) -> String {
"La tâche de fond {} est terminée : annulée.\nRaison : {}",
task.id, reason
),
Some(BackgroundTaskResult::CancelFailed { error, .. }) => format!(
"La tâche de fond {} est terminée : échec de l'annulation.\nErreur : {}",
task.id, error
),
Some(BackgroundTaskResult::Expired { reason, .. }) => format!(
"La tâche de fond {} est terminée : expirée.\nRaison : {}",
task.id, reason

View File

@ -1940,9 +1940,11 @@ fn background_task_state_label(state: BackgroundTaskState) -> &'static str {
BackgroundTaskState::Queued => "queued",
BackgroundTaskState::Running => "running",
BackgroundTaskState::Waiting => "waiting",
BackgroundTaskState::Cancelling => "cancelling",
BackgroundTaskState::Completed => "completed",
BackgroundTaskState::Failed => "failed",
BackgroundTaskState::Cancelled => "cancelled",
BackgroundTaskState::CancelFailed => "cancelFailed",
BackgroundTaskState::Expired => "expired",
}
}

View File

@ -689,6 +689,9 @@ fn flatten_background_result(
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
(None, Some(reason.clone()), None, None)
}
Some(BackgroundTaskResult::CancelFailed { error, .. }) => {
(None, Some(error.clone()), None, None)
}
None => (None, None, None, None),
}
}