Highlight Complete job step in execution view on job-completed pause

When the debugger pauses after job completion, surface the synthetic
"Complete job" entry as the active stack frame so clients highlight
the cleanup line. Previously the position stayed on the last real step.

Threads CompleteJobLine through RenderResult / JobExecutionView and
gates HandleStackTrace on a new _jobCompleted flag set when
OnJobCompletedAsync enters its inspection pause.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Francesco Renzi
2026-05-27 12:14:26 +01:00
parent 31c635f8a9
commit 88694b1d60
4 changed files with 85 additions and 4 deletions

View File

@@ -89,6 +89,11 @@ namespace GitHub.Runner.Worker.Dap
private IStep _currentStep;
private IExecutionContext _jobContext;
// Set true once OnJobCompletedAsync begins its inspection pause.
// While set, HandleStackTrace surfaces the synthetic "Complete job"
// frame so the client highlights the cleanup line.
private bool _jobCompleted;
// Client connection tracking for reconnection support
private volatile bool _isClientConnected;
@@ -244,6 +249,10 @@ namespace GitHub.Runner.Worker.Dap
{
if (_jobContext != null)
{
lock (_stateLock)
{
_jobCompleted = true;
}
Trace.Info("Job completed — pausing for inspection");
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
@@ -1380,10 +1389,12 @@ namespace GitHub.Runner.Worker.Dap
{
IStep currentStep;
JobExecutionView view;
bool jobCompleted;
lock (_stateLock)
{
currentStep = _currentStep;
view = _executionView;
jobCompleted = _jobCompleted;
}
var frames = new List<StackFrame>();
@@ -1392,9 +1403,23 @@ namespace GitHub.Runner.Worker.Dap
{
var source = BuildExecutionViewSource(view.JobId);
// Frame 0: the currently-executing step (only when one is set).
if (currentStep != null)
if (jobCompleted)
{
// Surface the synthetic Complete job step so the client
// highlights the cleanup line at end-of-job pause.
frames.Add(new StackFrame
{
Id = _currentFrameId,
Name = MaskUserVisibleText("Complete job"),
Line = view.CompleteJobLine,
Column = 1,
Source = source,
PresentationHint = "normal",
});
}
else if (currentStep != null)
{
// Frame 0: the currently-executing step (only when one is set).
var stepLine = view.TryGetLineForStep(currentStep) ?? 1;
frames.Add(new StackFrame
{

View File

@@ -40,6 +40,7 @@ namespace GitHub.Runner.Worker.Dap
new(StringComparer.Ordinal);
private string _yaml;
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
private int _completeJobLine;
public JobExecutionView(string jobId)
{
@@ -72,6 +73,21 @@ namespace GitHub.Runner.Worker.Dap
}
}
/// <summary>
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
/// </summary>
public int CompleteJobLine
{
get
{
lock (_lock)
{
return _completeJobLine;
}
}
}
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
public int EntryCount
{
@@ -261,6 +277,7 @@ namespace GitHub.Runner.Worker.Dap
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
_yaml = result.Yaml;
_entryStartLines = result.EntryStartLines;
_completeJobLine = result.CompleteJobLine;
_lineByStep.Clear();
for (int i = 0; i < _stepIdentities.Count; i++)

View File

@@ -95,14 +95,21 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
internal readonly struct RenderResult
{
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines)
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines, int completeJobLine)
{
Yaml = yaml;
EntryStartLines = entryStartLines;
CompleteJobLine = completeJobLine;
}
public string Yaml { get; }
public IReadOnlyList<int> EntryStartLines { get; }
/// <summary>
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
/// </summary>
public int CompleteJobLine { get; }
}
/// <summary>
@@ -160,9 +167,11 @@ namespace GitHub.Runner.Worker.Dap
// cleanup: section — always present, preceded by a blank line.
sb.Append('\n');
sb.Append("cleanup:\n");
newlinesEmitted += 2;
int completeJobLine = newlinesEmitted + 1;
sb.Append(" - step: Complete job\n");
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines));
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines), completeJobLine);
}
private static void EmitPhaseSection(

View File

@@ -517,6 +517,36 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Contains(" - step: Complete job\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_ReportsCompleteJobLineMatchingYaml()
{
// Empty entries — Cleanup still emitted.
var emptyResult = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
AssertCompleteJobLineMatchesYaml(emptyResult);
// Non-empty entries across phases.
var populatedResult = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
AssertCompleteJobLineMatchesYaml(populatedResult);
}
private static void AssertCompleteJobLineMatchesYaml(RenderResult result)
{
var lines = result.Yaml.Split('\n');
int? actual = null;
for (int i = 0; i < lines.Length; i++)
{
if (lines[i] == " - step: Complete job")
{
actual = i + 1;
break;
}
}
Assert.NotNull(actual);
Assert.Equal(actual.Value, result.CompleteJobLine);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]