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

@ -86,6 +86,12 @@ fn running_task(id: u128, project: u128, owner: u128, now_ms: u64) -> Background
.unwrap()
}
fn cancelling_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
running_task(id, project, owner, now_ms)
.transition(BackgroundTaskState::Cancelling, now_ms + 2)
.unwrap()
}
fn completed_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
running_task(id, project, owner, now_ms)
.complete(BackgroundTaskResult::Success {
@ -337,6 +343,31 @@ async fn reconcile_marks_running_without_live_handle_failed_and_pending() {
);
}
#[tokio::test]
async fn reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending() {
let tmp = TempDir::new();
let store = FsBackgroundTaskStore::new(&tmp.project_path());
let lost = cancelling_task(1, 10, 100, 1_000);
store.create(&lost).await.unwrap();
let report = store.reconcile_boot(&[], 2_000).await.unwrap();
assert_eq!(report.failed_task_ids, vec![lost.id]);
assert_eq!(report.delivery_pending_task_ids, vec![lost.id]);
let lost_after = store.get(lost.id).await.unwrap().unwrap();
assert_eq!(lost_after.state, BackgroundTaskState::CancelFailed);
assert!(lost_after.has_pending_completion_delivery());
assert!(matches!(
lost_after.result,
Some(BackgroundTaskResult::CancelFailed {
finished_at_ms: 2_000,
..
})
));
}
#[tokio::test]
async fn reconcile_uses_monotonic_timestamp_when_clock_is_behind() {
let tmp = TempDir::new();