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

@ -33,6 +33,7 @@ struct FakePtyState {
handle: Option<PtyHandle>,
status: Option<ExitStatus>,
kills: usize,
fail_kill: bool,
scrollback: Vec<u8>,
}
@ -57,6 +58,10 @@ impl FakePty {
self.state.lock().unwrap().kills
}
fn fail_kill(&self) {
self.state.lock().unwrap().fail_kill = true;
}
fn subscribe_count(&self) -> usize {
self.subscribe_count.load(Ordering::SeqCst)
}
@ -105,6 +110,9 @@ impl PtyPort for FakePty {
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
let mut state = self.state.lock().unwrap();
state.kills += 1;
if state.fail_kill {
return Err(PtyError::Io("kill denied".to_owned()));
}
let status = state.status.get_or_insert(ExitStatus { code: Some(130) });
Ok(*status)
}
@ -314,6 +322,31 @@ async fn deadline_expires_and_kills_when_wait_never_resolves() {
assert_eq!(pty.subscribe_count(), 0);
}
#[tokio::test]
async fn cancel_surfaces_pty_kill_failure_without_completion() {
let pty = Arc::new(FakePty::default());
pty.fail_kill();
let clock = Arc::new(FakeClock::default());
let (runner, _completions) = runner_with(Arc::clone(&pty), clock);
let task_id = TaskId::from_uuid(id(16));
runner
.spawn(spawn_spec(task_id, None))
.await
.expect("spawn succeeds");
let err = runner
.cancel(task_id)
.await
.expect_err("kill failure surfaces");
assert!(matches!(err, domain::BackgroundTaskPortError::Runner(_)));
assert_eq!(pty.kill_count(), 1);
assert!(
runner.pty_handle_for(task_id).is_some(),
"failed cancel keeps the live handle available for diagnostics/retry"
);
}
#[tokio::test]
async fn cancel_wins_even_if_output_is_still_available() {
let pty = Arc::new(FakePty::default());