diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index a974d1fcdc9..99e916a7177 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -1648,7 +1648,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { const isAgentCancelled = agentConclusion === "cancelled"; const isAgentNonOK = isAgentFailure || isAgentCancelled; // STATUS_CODE_ERROR = 2, STATUS_CODE_OK = 1 - const statusCode = isAgentNonOK ? 2 : 1; + let statusCode = isAgentNonOK ? 2 : 1; let statusMessage; if (isAgentFailure) { statusMessage = `agent ${agentConclusion}`; @@ -1675,6 +1675,15 @@ async function sendJobConclusionSpan(spanName, options = {}) { runStatus = "failure"; } + // When GH_AW_AGENT_CONCLUSION and workflowRunConclusion are both absent (e.g. in the + // agent job's own post-step where needs..result is not yet visible), fall back to + // observable failure evidence so gh-aw.run.status and status.code are accurate. + if (!rawRunStatus && outputErrors.length > 0) { + runStatus = "failure"; + statusCode = 2; + statusMessage = (errorMessages.length > 0 ? `errors detected: ${errorMessages[0]}` : "errors detected").slice(0, 256); + } + if (isAgentFailure && errorMessages.length > 0) { statusMessage = `agent ${agentConclusion}: ${errorMessages[0]}`.slice(0, 256); } diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index 1d448e37c22..a984a43bd1c 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -4292,6 +4292,110 @@ describe("sendJobConclusionSpan", () => { }); }); + describe("run.status fallback from observable error signals", () => { + let readFileSpy; + + beforeEach(() => { + readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + }); + + afterEach(() => { + readFileSpy.mockRestore(); + }); + + it("sets gh-aw.run.status=failure and STATUS_CODE_ERROR when conclusion is absent but outputErrors exist", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]); + // GH_AW_AGENT_CONCLUSION intentionally not set (simulates agent job post-step) + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/agent_output.json") { + return JSON.stringify({ errors: [{ message: "push_to_pull_request_branch:push failed" }] }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(span.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue ?? a.value.boolValue])); + expect(attrs["gh-aw.run.status"]).toBe("failure"); + expect(span.status.code).toBe(2); + expect(span.status.message).toBe("errors detected: push_to_pull_request_branch:push failed"); + }); + + it("includes the first error message in statusMessage when conclusion is absent", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]); + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/agent_output.json") { + return JSON.stringify({ errors: [{ message: "Rate limit exceeded" }, { message: "second error" }] }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + expect(span.status.message).toBe("errors detected: Rate limit exceeded"); + }); + + it("does not override explicit GH_AW_AGENT_CONCLUSION=success even when outputErrors exist", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]); + process.env.GH_AW_AGENT_CONCLUSION = "success"; + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/agent_output.json") { + return JSON.stringify({ errors: [{ message: "non-fatal warning error" }] }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(span.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue ?? a.value.boolValue])); + expect(attrs["gh-aw.run.status"]).toBe("success"); + expect(span.status.code).toBe(1); + }); + + it("keeps gh-aw.run.status=success and STATUS_CODE_OK when conclusion is absent and there are no outputErrors", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]); + // No GH_AW_AGENT_CONCLUSION and no errors + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/agent_output.json") { + return JSON.stringify({ items: [{ type: "pull_request" }] }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(span.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue ?? a.value.boolValue])); + expect(attrs["gh-aw.run.status"]).toBe("success"); + expect(span.status.code).toBe(1); + }); + }); + describe("rate-limit enrichment in conclusion span", () => { let readFileSpy;