From 275ab5c3f1da34613a67d599671d3bfd0b665ea9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:49:48 +0000 Subject: [PATCH 1/2] Initial plan From c3be7991ebe62cd103a96409e48ddea72a3be315 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:01:13 +0000 Subject: [PATCH 2/2] fix: emit gh-aw.agent.agent sub-span for cancelled workflow runs When a workflow run is manually cancelled while the agent is executing, agent_output.json may not exist because the process was killed before writing output. The statSync fallback that sets agentEndMs = nowMs() was previously only applied for isAgentFailure ("failure" | "timed_out"). Include isAgentCancelled in the condition so the dedicated agent sub-span is also emitted for cancelled runs, giving on-call engineers visibility into how long the agent ran before cancellation. Also add a corresponding test case for the cancelled scenario. Fixes #[issue] Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7424f8db-1e3b-431d-bf7a-fec3562805db Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/send_otlp_span.cjs | 9 ++++--- actions/setup/js/send_otlp_span.test.cjs | 32 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index fce590318d5..29fb64a739f 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -856,10 +856,11 @@ async function sendJobConclusionSpan(spanName, options = {}) { try { agentEndMs = fs.statSync("/tmp/gh-aw/agent_output.json").mtimeMs; } catch { - // agent_output.json may be absent for agent failures, including timed-out - // runs where the process was killed before writing output. Fall back to - // nowMs() so we still emit the dedicated agent span for these failures. - if (isAgentFailure && jobName === "agent" && typeof agentStartMs === "number" && agentStartMs > 0) { + // agent_output.json may be absent for agent failures and cancellations, + // including timed-out or manually-cancelled runs where the process was + // killed before writing output. Fall back to nowMs() so we still emit + // the dedicated agent span for these cases. + if ((isAgentFailure || isAgentCancelled) && jobName === "agent" && typeof agentStartMs === "number" && agentStartMs > 0) { agentEndMs = nowMs(); } } diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index dcc78ad6451..50cd0325983 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -1706,6 +1706,38 @@ describe("sendJobConclusionSpan", () => { expect(conclusionSpan.status.message).toContain("agent timed_out"); }); + it("emits a dedicated agent span on cancelled when agent_output mtime is unavailable", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + process.env.INPUT_JOB_NAME = "agent"; + process.env.GH_AW_AGENT_CONCLUSION = "cancelled"; + + const startMs = 1_700_000_000_000; + const statSpy = vi.spyOn(fs, "statSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs }); + + statSpy.mockRestore(); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + expect(agentSpan.name).toBe("gh-aw.agent.agent"); + expect(agentSpan.startTimeUnixNano).toBe(toNanoString(startMs)); + expect(BigInt(agentSpan.endTimeUnixNano)).toBeGreaterThan(BigInt(toNanoString(startMs))); + + const conclusionBody = JSON.parse(mockFetch.mock.calls[1][1].body); + const conclusionSpan = conclusionBody.resourceSpans[0].scopeSpans[0].spans[0]; + expect(conclusionSpan.name).toBe("gh-aw.agent.conclusion"); + expect(agentSpan.parentSpanId).toBe(conclusionSpan.spanId); + expect(conclusionSpan.status.code).toBe(2); + expect(conclusionSpan.status.message).toContain("agent cancelled"); + }); + it("does not emit a dedicated agent span for non-agent jobs", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch);