@@ -32,6 +32,11 @@ public sealed class BackgroundStepCoordinator : RunnerService, IBackgroundStepCo
3232 // IDs of background steps that have already been completed (waited on or canceled).
3333 // Used to avoid waiting on or flushing the same step more than once.
3434 private readonly HashSet < string > _completedStepIds = new ( ) ;
35+
36+ // IDs of background steps that were explicitly canceled via a `cancel` control step.
37+ // These steps are expected to be canceled, so their (Canceled) result must not be
38+ // merged into the overall job result.
39+ private readonly HashSet < string > _explicitlyCanceledStepIds = new ( ) ;
3540 private SemaphoreSlim _backgroundSlotSemaphore = new SemaphoreSlim ( DefaultMaxBackgroundSteps ) ;
3641
3742 /// <summary>
@@ -41,6 +46,7 @@ public void InitializeCoordinator(int maxConcurrent)
4146 {
4247 _backgroundSteps . Clear ( ) ;
4348 _completedStepIds . Clear ( ) ;
49+ _explicitlyCanceledStepIds . Clear ( ) ;
4450 var max = maxConcurrent > 0 ? maxConcurrent : DefaultMaxBackgroundSteps ;
4551 _backgroundSlotSemaphore = new SemaphoreSlim ( max ) ;
4652 }
@@ -85,6 +91,9 @@ public void StartBackgroundStep(IStep step, CancellationToken jobCancellationTok
8591 // Safety net
8692 // -----------------------------------------------------------------
8793
94+ // Drain any background steps that weren't already waited on by an explicit wait/cancel
95+ // control step, then merge the final results of all background steps into a single result
96+ // for the caller to fold into the job result.
8897 public async Task < TaskResult > WaitForUnwaitedStepsAsync ( CancellationToken cancellationToken )
8998 {
9099 var unwaitedIds = _backgroundSteps . Keys . Where ( id => ! _completedStepIds . Contains ( id ) ) . ToList ( ) ;
@@ -95,14 +104,26 @@ public async Task<TaskResult> WaitForUnwaitedStepsAsync(CancellationToken cancel
95104 CompleteWaitedSteps ( unwaitedIds ) ;
96105 }
97106
98- // Report the merged result of all background steps; the caller merges this into the job result.
99107 var result = TaskResult . Succeeded ;
100- foreach ( var ( _ , ( step , _, _) ) in _backgroundSteps )
108+ foreach ( var ( stepId , ( step , _, _) ) in _backgroundSteps )
101109 {
102- if ( step . ExecutionContext . Result . HasValue )
110+ // A step that succeeded does not set a Result by default, so a missing
111+ // value means the step succeeded and there is nothing to merge.
112+ if ( ! step . ExecutionContext . Result . HasValue )
113+ {
114+ continue ;
115+ }
116+
117+ // A step explicitly canceled via a `cancel` control step is expected to be canceled,
118+ // so a Canceled result must not influence the overall job result. However, if the step
119+ // failed (e.g. before the cancellation took effect), that failure should still count.
120+ if ( _explicitlyCanceledStepIds . Contains ( stepId ) &&
121+ step . ExecutionContext . Result . Value == TaskResult . Canceled )
103122 {
104- result = TaskResultUtil . MergeTaskResults ( result , step . ExecutionContext . Result . Value ) ;
123+ continue ;
105124 }
125+
126+ result = TaskResultUtil . MergeTaskResults ( result , step . ExecutionContext . Result . Value ) ;
106127 }
107128
108129 if ( result != TaskResult . Succeeded )
@@ -256,6 +277,13 @@ private async Task CancelStepsAsync(string[] cancelStepIds)
256277 return ;
257278 }
258279
280+ // Mark these steps as expected-to-be-canceled so their result does not
281+ // affect the overall job result.
282+ foreach ( var id in cancelStepIds )
283+ {
284+ _explicitlyCanceledStepIds . Add ( id ) ;
285+ }
286+
259287 var idsToCancel = cancelStepIds
260288 . Where ( id => _backgroundSteps . ContainsKey ( id ) && ! _backgroundSteps [ id ] . Task . IsCompleted )
261289 . ToArray ( ) ;
0 commit comments