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:
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user