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

@ -139,11 +139,14 @@ impl CommandBackgroundRunner {
.filter(|s| !s.is_empty());
// Reap the real exit status (works for a natural exit and a forced kill).
let exit_code = pty
.kill(&control.pty_handle)
.await
.ok()
.and_then(|status| status.code);
let exit_code = match outcome {
WaitOutcome::Cancelled => None,
WaitOutcome::Expired | WaitOutcome::Exited => pty
.kill(&control.pty_handle)
.await
.ok()
.and_then(|status| status.code),
};
let finished_at_ms = u64::try_from(clock.now_millis().max(0)).unwrap_or(0);
let result = match outcome {
@ -281,8 +284,12 @@ impl BackgroundTaskRunner for CommandBackgroundRunner {
.cloned();
// Unknown task ⇒ already terminal or never ours: cancellation is idempotent.
if let Some(control) = control {
self.pty
.kill(&control.pty_handle)
.await
.map_err(|e| BackgroundTaskPortError::Runner(format!("pty kill failed: {e}")))?;
control.cancelled.store(true, Ordering::SeqCst);
control.cancel.notify_one();
control.cancel.notify_waiters();
}
Ok(())
}

View File

@ -108,7 +108,8 @@ impl FsBackgroundTaskStore {
/// Reconciles persisted tasks against live runtime handles at boot.
///
/// B2 rule: `Running`/`Waiting` tasks missing from `live_task_ids` are marked
/// `Failed` with a synthetic restart-loss failure and become delivery-pending.
/// `Failed`; `Cancelling` tasks missing from `live_task_ids` are marked
/// `CancelFailed`. Both become delivery-pending.
/// Already-terminal undelivered tasks are reported as delivery-pending. `Queued`
/// tasks are left open.
///
@ -127,18 +128,13 @@ impl FsBackgroundTaskStore {
for (project_id, doc) in &mut state.docs {
for task in &mut doc.tasks {
match task.state {
BackgroundTaskState::Running | BackgroundTaskState::Waiting
BackgroundTaskState::Running
| BackgroundTaskState::Waiting
| BackgroundTaskState::Cancelling
if !live.contains(&task.id) =>
{
let finished_at_ms = now_ms.max(task.updated_at_ms);
let result = BackgroundTaskResult::Failure {
finished_at_ms,
exit_code: None,
error: "background task lost its runtime handle during IdeA restart"
.into(),
stdout_tail: None,
stderr_tail: None,
};
let result = lost_runtime_result(task.state, finished_at_ms);
*task = task.complete(result).map_err(invalid_task)?;
report.failed_task_ids.push(task.id);
report.delivery_pending_task_ids.push(task.id);
@ -158,18 +154,13 @@ impl FsBackgroundTaskStore {
for task in &mut doc.tasks {
if matches!(
task.state,
BackgroundTaskState::Running | BackgroundTaskState::Waiting
BackgroundTaskState::Running
| BackgroundTaskState::Waiting
| BackgroundTaskState::Cancelling
) && !live.contains(&task.id)
{
let finished_at_ms = now_ms.max(task.updated_at_ms);
let result = BackgroundTaskResult::Failure {
finished_at_ms,
exit_code: None,
error: "background task lost its runtime handle during IdeA restart"
.into(),
stdout_tail: None,
stderr_tail: None,
};
let result = lost_runtime_result(task.state, finished_at_ms);
*task = task.complete(result).map_err(invalid_task)?;
}
}
@ -444,3 +435,21 @@ fn io_error(error: std::io::Error) -> BackgroundTaskPortError {
fn invalid_task(error: domain::BackgroundTaskError) -> BackgroundTaskPortError {
BackgroundTaskPortError::Invalid(error.to_string())
}
fn lost_runtime_result(state: BackgroundTaskState, finished_at_ms: u64) -> BackgroundTaskResult {
if state == BackgroundTaskState::Cancelling {
BackgroundTaskResult::CancelFailed {
finished_at_ms,
error: "background task cancellation lost its runtime handle during IdeA restart"
.into(),
}
} else {
BackgroundTaskResult::Failure {
finished_at_ms,
exit_code: None,
error: "background task lost its runtime handle during IdeA restart".into(),
stdout_tail: None,
stderr_tail: None,
}
}
}