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")]