From 88694b1d60037e7800b4fd5e67aef616ebfd4217 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 27 May 2026 12:14:26 +0100 Subject: [PATCH] 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> --- src/Runner.Worker/Dap/DapDebugger.cs | 29 ++++++++++++++++-- src/Runner.Worker/Dap/JobExecutionView.cs | 17 +++++++++++ .../Dap/JobExecutionViewRenderer.cs | 13 ++++++-- .../L0/Worker/JobExecutionViewRendererL0.cs | 30 +++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index caa4a9efd..f4314310d 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -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(); @@ -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 { diff --git a/src/Runner.Worker/Dap/JobExecutionView.cs b/src/Runner.Worker/Dap/JobExecutionView.cs index 7e0367067..016b2190c 100644 --- a/src/Runner.Worker/Dap/JobExecutionView.cs +++ b/src/Runner.Worker/Dap/JobExecutionView.cs @@ -40,6 +40,7 @@ namespace GitHub.Runner.Worker.Dap new(StringComparer.Ordinal); private string _yaml; private IReadOnlyList _entryStartLines = Array.Empty(); + private int _completeJobLine; public JobExecutionView(string jobId) { @@ -72,6 +73,21 @@ namespace GitHub.Runner.Worker.Dap } } + /// + /// 1-based line where the synthetic - step: Complete job entry + /// appears in . Always non-zero — Cleanup is always emitted. + /// + public int CompleteJobLine + { + get + { + lock (_lock) + { + return _completeJobLine; + } + } + } + /// Number of entries (excludes synthetic Setup/Cleanup boundaries). 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++) diff --git a/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs b/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs index b5ac74b0e..a3f6e0ff7 100644 --- a/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs +++ b/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs @@ -95,14 +95,21 @@ namespace GitHub.Runner.Worker.Dap /// internal readonly struct RenderResult { - public RenderResult(string yaml, IReadOnlyList entryStartLines) + public RenderResult(string yaml, IReadOnlyList entryStartLines, int completeJobLine) { Yaml = yaml; EntryStartLines = entryStartLines; + CompleteJobLine = completeJobLine; } public string Yaml { get; } public IReadOnlyList EntryStartLines { get; } + + /// + /// 1-based line where the synthetic - step: Complete job entry + /// appears in . Always non-zero — Cleanup is always emitted. + /// + public int CompleteJobLine { get; } } /// @@ -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( diff --git a/src/Test/L0/Worker/JobExecutionViewRendererL0.cs b/src/Test/L0/Worker/JobExecutionViewRendererL0.cs index f13275113..6e87af8ec 100644 --- a/src/Test/L0/Worker/JobExecutionViewRendererL0.cs +++ b/src/Test/L0/Worker/JobExecutionViewRendererL0.cs @@ -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()); + 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")]