Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/Runner.Worker/BackgroundStepControlFlowData.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
using System;

namespace GitHub.Runner.Worker
{
/// <summary>
/// Pure data for control-flow steps (wait, wait-all, cancel).
/// Type uses Pipelines.BackgroundControlTypes string constants.
/// </summary>
public sealed class BackgroundStepControlFlowData
{
public string Type { get; set; }
public Guid StepId { get; set; }
public string StepName { get; set; }

// Target step IDs (for wait: steps to wait for; for cancel: steps to cancel)
public string[] StepIds { get; set; }

// Parallel group ID for grouping steps in the UI
public string ParallelGroupId { get; set; }
}
}
366 changes: 366 additions & 0 deletions src/Runner.Worker/BackgroundStepCoordinator.cs

Large diffs are not rendered by default.

31 changes: 29 additions & 2 deletions src/Runner.Worker/ExecutionContext.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,12 +77,14 @@ public interface IExecutionContext : IRunnerService

List<string> StepEnvironmentOverrides { get; }

bool IsBackground { get; }

IExecutionContext Root { get; }

// Initialize
void InitializeJob(Pipelines.AgentJobRequestMessage message, CancellationToken token);
void CancelToken();
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null);
Comment thread
lokesh755 marked this conversation as resolved.
IExecutionContext CreateChild(Guid recordId, string displayName, string refName, string scopeName, string contextName, ActionRunStage stage, Dictionary<string, string> intraActionState = null, int? recordOrder = null, IPagingLogger logger = null, bool isEmbedded = false, List<Issue> embeddedIssueCollector = null, CancellationTokenSource cancellationTokenSource = null, Guid embeddedId = default(Guid), string siblingScopeName = null, TimeSpan? timeout = null, bool isBackground = false, string backgroundControlType = null, string[] backgroundControlStepIds = null, string parallelGroupId = null);
IExecutionContext CreateEmbeddedChild(string scopeName, string contextName, Guid embeddedId, ActionRunStage stage, Dictionary<string, string> intraActionState = null, string siblingScopeName = null);


Expand DownExpand Up@@ -229,6 +231,9 @@ private ExecutionContext(ExecutionContext parent, bool embedded)

public bool EchoOnActionCommand { get; set; }

// Whether this step runs in the background
public bool IsBackground => _record.IsBackground;

// An embedded execution context shares the same record ID, record name, and logger
// as its enclosing execution context.
public bool IsEmbedded { get; private init; }
Expand DownExpand Up@@ -392,7 +397,11 @@ public IExecutionContext CreateChild(
CancellationTokenSource cancellationTokenSource = null,
Guid embeddedId = default(Guid),
string siblingScopeName = null,
TimeSpan? timeout = null)
TimeSpan? timeout = null,
bool isBackground = false,
string backgroundControlType = null,
string[] backgroundControlStepIds = null,
string parallelGroupId = null)
{
Trace.Entering();

Expand DownExpand Up@@ -433,6 +442,24 @@ public IExecutionContext CreateChild(

child.EchoOnActionCommand = EchoOnActionCommand;

// Set background step metadata before InitializeTimelineRecord so it's included in the first update
if (isBackground || backgroundControlType != null || parallelGroupId != null)
{
child._record.IsBackground = isBackground;
child._record.BackgroundControlType = backgroundControlType;
child._record.BackgroundControlStepIds = backgroundControlStepIds;
child._record.ParallelGroupId = parallelGroupId;

// Initialize deferred state for background steps — flushed at wait/wait-all
if (isBackground)
{
child.DeferredOutputs = new Dictionary<string, string>();
child.DeferredEnvironmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
child.DeferredPrependPath = new List<string>();
child.DeferOutcomeConclusion = true;
}
}

if (recordOrder != null)
{
child.InitializeTimelineRecord(_mainTimelineId, recordId, _record.Id, ExecutionContextType.Task, displayName, refName, recordOrder, embedded: isEmbedded);
Expand Down
128 changes: 127 additions & 1 deletion src/Runner.Worker/JobExtension.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -345,6 +345,38 @@ public async Task<List<IStep>> InitializeJob(IExecutionContext jobContext, Pipel
preJobSteps.Add(preStep);
}
}
else if (step.Type == Pipelines.StepType.BackgroundStepControl)
{
var ctrl = step as Pipelines.BackgroundStepControl;
Trace.Info($"Adding {ctrl.ControlType} step for: {string.Join(", ", ctrl.StepIds ?? Array.Empty<string>())}");
var controlType = ctrl.ControlType;
if (string.IsNullOrEmpty(controlType))
{
throw new ArgumentException($"Background step control '{step.Name}' has no control type.");
}
if (controlType != Pipelines.BackgroundControlTypes.Wait &&
controlType != Pipelines.BackgroundControlTypes.WaitAll &&
controlType != Pipelines.BackgroundControlTypes.Cancel)
{
throw new ArgumentException($"Unknown background step control type '{controlType}' for step '{step.Name}'.");
}
var displayName = (ctrl.DisplayNameToken as GitHub.DistributedTask.ObjectTemplating.Tokens.StringToken)?.Value
?? step.DisplayName ?? step.Name ?? ctrl.ControlType;
var data = new BackgroundStepControlFlowData
{
Type = controlType,
StepId = step.Id,
StepName = step.Name,
StepIds = ctrl.StepIds,
ParallelGroupId = ctrl.ParallelGroupId,
};
var bgCoord = HostContext.GetService<IBackgroundStepCoordinator>();
jobSteps.Add(new JobExtensionRunner(
runAsync: bgCoord.RunControlFlowAsync,
condition: $"{PipelineTemplateConstants.Always}()",
displayName: displayName,
data: data));
}
}

if (message.Variables.TryGetValue("system.workflowFileFullPath", out VariableValue workflowFileFullPath))
Expand DownExpand Up@@ -400,13 +432,107 @@ public async Task<List<IStep>> InitializeJob(IExecutionContext jobContext, Pipel
}

// Create execution context for job steps
// Build mapping of logical step ID (ContextName) → external ID (timeline record GUID)
// so wait/cancel steps can reference background steps by external ID.
var contextNameToExternalId = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var hasBackgroundSteps = false;
var backgroundStepExternalIds = new List<string>();

// Track which background steps are explicitly covered by wait/wait-all/cancel
var coveredBackgroundIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

foreach (var step in jobSteps)
{
if (step is IActionRunner actionStep)
{
ArgUtil.NotNull(actionStep, step.DisplayName);
intraActionStates.TryGetValue(actionStep.Action.Id, out var intraActionState);
actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState);

var isBg = actionStep.Action?.Background == true;
actionStep.ExecutionContext = jobContext.CreateChild(
actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name,
null, actionStep.Action.ContextName, ActionRunStage.Main, intraActionState,
isBackground: isBg,
parallelGroupId: isBg ? actionStep.Action.ParallelGroupId : null);

if (isBg)
{
hasBackgroundSteps = true;
var externalId = actionStep.Action.Id.ToString("N");
contextNameToExternalId[actionStep.Action.ContextName] = externalId;
backgroundStepExternalIds.Add(externalId);
}
}
else if (step is JobExtensionRunner runnerStep && runnerStep.Data is BackgroundStepControlFlowData cf)
{
// Resolve step IDs to external IDs and track coverage
string[] externalIds = null;
if (cf.StepIds != null && cf.StepIds.Length > 0)
{
foreach (var id in cf.StepIds)
{
coveredBackgroundIds.Add(id);
}
externalIds = cf.StepIds
.Where(id => contextNameToExternalId.ContainsKey(id))
.Select(id => contextNameToExternalId[id])
.ToArray();
}

if (cf.Type == Pipelines.BackgroundControlTypes.WaitAll)
{
externalIds = backgroundStepExternalIds.Count > 0 ? backgroundStepExternalIds.ToArray() : null;
foreach (var id in contextNameToExternalId.Keys)
{
coveredBackgroundIds.Add(id);
}
}

step.ExecutionContext = jobContext.CreateChild(
cf.StepId, step.DisplayName, cf.StepName,
null, cf.StepName, ActionRunStage.Main,
backgroundControlType: cf.Type,
backgroundControlStepIds: externalIds,
parallelGroupId: cf.ParallelGroupId);
}
}

// Add implicit wait-all only if there are background steps not covered by any wait/wait-all/cancel
var allBackgroundIds = contextNameToExternalId.Keys;
var hasUncoveredBackgroundSteps = allBackgroundIds.Any(id => !coveredBackgroundIds.Contains(id));
if (hasBackgroundSteps)
{
// Initialize coordinator only when there are background steps
var bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();
var maxBgSteps = jobContext.Global.Variables.GetInt("system.runner.maxbackgroundsteps");
var maxConcurrent = (maxBgSteps.HasValue && maxBgSteps.Value > 0) ? maxBgSteps.Value : 10;
bgCoordinator.InitializeCoordinator(maxConcurrent);

// Add implicit wait-all only if there are uncovered background steps
if (hasUncoveredBackgroundSteps)
{
var implicitStepId = Guid.NewGuid();
var implicitWaitAllData = new BackgroundStepControlFlowData
{
Type = Pipelines.BackgroundControlTypes.WaitAll,
StepId = implicitStepId,
StepName = "__implicit_wait_all",
};
var implicitWaitAll = new JobExtensionRunner(
runAsync: bgCoordinator.RunControlFlowAsync,
condition: $"{PipelineTemplateConstants.Always}()",
displayName: "Wait for all background steps",
data: implicitWaitAllData);
var uncoveredExternalIds = contextNameToExternalId
.Where(kvp => !coveredBackgroundIds.Contains(kvp.Key))
.Select(kvp => kvp.Value)
.ToArray();
implicitWaitAll.ExecutionContext = jobContext.CreateChild(
implicitStepId, implicitWaitAll.DisplayName, "__implicit_wait_all",
null, "__implicit_wait_all", ActionRunStage.Main,
backgroundControlType: Pipelines.BackgroundControlTypes.WaitAll,
backgroundControlStepIds: uncoveredExternalIds.Length > 0 ? uncoveredExternalIds : null);
jobSteps.Add(implicitWaitAll);
}
}

Expand Down
38 changes: 30 additions & 8 deletions src/Runner.Worker/StepsRunner.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,8 @@ public async Task RunAsync(IExecutionContext jobContext)
ArgUtil.NotNull(jobContext, nameof(jobContext));
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));

var _bgCoordinator = HostContext.GetService<IBackgroundStepCoordinator>();

// TaskResult:
// Abandoned (Server set this.)
// Canceled
Expand All@@ -57,6 +59,15 @@ public async Task RunAsync(IExecutionContext jobContext)
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
{
checkPostJobActions = true;

// Safety net: wait for any unwaited background steps before post-hooks
var backgroundResult = await _bgCoordinator.WaitForUnwaitedStepsAsync(jobContext.CancellationToken);
if (backgroundResult != TaskResult.Succeeded)
{
jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, backgroundResult);
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
}

while (jobContext.PostJobSteps.TryPop(out var postStep))
{
jobContext.JobSteps.Enqueue(postStep);
Expand All@@ -72,8 +83,11 @@ public async Task RunAsync(IExecutionContext jobContext)
ArgUtil.NotNull(step.ExecutionContext.Global, nameof(step.ExecutionContext.Global));
ArgUtil.NotNull(step.ExecutionContext.Global.Variables, nameof(step.ExecutionContext.Global.Variables));

// Start
step.ExecutionContext.Start();
// Start — defer for background steps until the slot is acquired
if (!step.ExecutionContext.IsBackground)
{
step.ExecutionContext.Start();
}

// Expression functions
step.ExecutionContext.ExpressionFunctions.Add(new FunctionInfo<AlwaysFunction>(PipelineTemplateConstants.Always, 0, 0));
Expand DownExpand Up@@ -228,14 +242,22 @@ public async Task RunAsync(IExecutionContext jobContext)
}
else
{
// Pause for DAP debugger before step execution
await dapDebugger?.OnStepStartingAsync(step);
if (step.ExecutionContext.IsBackground)
{
// Queue the background step via coordinator
_bgCoordinator.StartBackgroundStep(step, jobContext.CancellationToken);
}
else
{
// Pause for DAP debugger before step execution
await dapDebugger?.OnStepStartingAsync(step);

// Run the step
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);
// Run the step synchronously (normal behavior)
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step);

dapDebugger?.OnStepCompleted(step);
dapDebugger?.OnStepCompleted(step);
}
}
}
finally
Expand Down
Loading
Loading