Skip the pause transition when a task is no longer Executing
Summary
- All three pause outcomes (suspend, timeout, transient) share one guard that writes
Pausedonly fromExecuting, the sole legal source of that edge. The timeout and transient handlers previously transitioned blind;OnPausedAsyncalready had the check. -
The database outcome is unchanged in every case.
TaskState.EnsureValidTransitionis the first statement ofTransitionStateAsync, before any SQL, andDeploymentPipelineRunner.SafeCompleteAsyncswallows what it throws — so a blind transition left the row exactly as the guard now leaves it, with an empty transaction rolled back. What changes is that an expected race stops being signalled by an exception. - Removing the exception removes the Error that
SafeCompleteAsynclogged, which on the timeout and transient paths was the only trace that aCancellingtask had permanently lost its environment's concurrency slot. TheCancellingskip is now logged at Warning naming that consequence; other skipped states stay Information so the routinePausedcase does not bury it. For the suspend outcome, which already skipped and logged Information, Warning is a deliberate uplift — the same wedged state should not be reported at two levels depending on which outcome reached it.
What this does not fix
A task left Cancelling is still stuck: it holds the environment's concurrency slot, its only edges out (Cancelled, Failed) exist solely in DeploymentCompletionHandler and so need a live pipeline, CancelTaskAsync returns early on Cancelling, ResumeTaskAsync throws unless Paused, and no recurring job reaps it. Freeing it needs cross-pod cancel propagation or a stale-active-task reaper; both are pre-existing and out of scope here.
The success path does not share this. OnSuccessAsync also transitions from a hardcoded Executing, but it is the one completion callback the runner invokes directly rather than through SafeCompleteAsync, so its exception reaches the general catch, which routes to OnFailureAsync; that resolves the current state and performs the legal Cancelling → Failed. The row ends terminal and the slot is freed — at the cost of a deployment that succeeded being recorded as failed.
Test plan
-
Unit: 3 Theories over all three pause outcomes × (raced by cancel / not Executing / still Executing) -
Unit: 9 log-level cases pinning Warning for Cancelling, Information for every other skipped state -
Unit: runner test pinning that an illegal transition out of OnSuccessAsyncfalls through toOnFailureAsync -
Mutation: downgrading the Cancellingalert, warning on every skip, and dropping the consequence from the message each fail tests (3 / 9 / 3); routingOnSuccessAsyncthroughSafeCompleteAsyncfails the new runner test -
Full unit suite green (6200)