Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-retry-gateway-startup.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions actions/setup/js/constants.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ const SCAN_WINDOW_MS = 24 * 60 * 60 * 1000;
*/
const PROXY_ENV_NAMES = Object.freeze(["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"]);

/**
* GitHub Actions step name for starting the MCP gateway.
* @type {string}
*/
const STEP_NAME_START_MCP_GATEWAY = "Start MCP Gateway";

module.exports = {
AGENT_OUTPUT_FILENAME,
TMP_GH_AW_PATH,
Expand Down Expand Up @@ -231,4 +237,5 @@ module.exports = {
MAX_RESTORE_PAGES,
SCAN_WINDOW_MS,
PROXY_ENV_NAMES,
STEP_NAME_START_MCP_GATEWAY,
};
74 changes: 66 additions & 8 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const { parseTokenUsageJsonl, generateTokenUsageSummary } = require("./parse_mcp
const { readDedupedTokenUsage, getTokenUsagePaths } = require("./parse_token_usage.cjs");
const { extractShellCommandDetailsFromToolData } = require("./tool_call_details.cjs");
const { resolveFailureIssueRepo } = require("./repo_helpers.cjs");
const { GITHUB_API_VERSION } = require("./constants.cjs");
const { GITHUB_API_VERSION, STEP_NAME_START_MCP_GATEWAY } = require("./constants.cjs");
const { buildEmptyOutputOutcome, buildCompletionEvidence, EMPTY_OUTPUT_CAUSES, EMPTY_OUTPUT_FAILURE_CAUSES } = require("./empty_output_outcome.cjs");
const { isAgentExecutionEvent } = require("./agent_execution.cjs");
const { collectUnifiedSession, isModelEndpointMismatchData } = require("./unified_session.cjs");
Expand All @@ -53,6 +53,27 @@ const path = require("path");

const DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = 24 * 7;
const ALLOWED_AGENT_CONCLUSIONS = new Set(["success", "failure", "cancelled", "skipped", "neutral", "timed_out", "action_required", "stale"]);
const EXECUTE_AGENT_STEP_NAME_RE = /^Execute .+ CLI$/;

function isExecuteAgentStep(step) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good decomposition into small, focused predicate functions (isExecuteAgentStep, isGatewayStartupStep, isCheckoutStep). This makes the failure attribution logic much easier to follow and test in isolation. ✅

return typeof step.name === "string" && EXECUTE_AGENT_STEP_NAME_RE.test(step.name);
}

function isGatewayStartupStep(step) {
return step.name === STEP_NAME_START_MCP_GATEWAY;
}

function isCheckoutStep(step) {
return typeof step.name === "string" && step.name.startsWith("Checkout ");
}

function isSetupFailureStep(step) {
return isGatewayStartupStep(step) || isCheckoutStep(step);
}

function isFailedStep(step) {
return ["failure", "timed_out"].includes(step.conclusion);
}
/** Claude Code error emitted when a `--continue` resume finds no deferred tool marker. */
const NO_DEFERRED_MARKER_LINE_RE = /No deferred tool marker found/i;
/** Claude harness line reporting that the no-deferred-marker error was recovered by a fresh retry. */
Expand Down Expand Up @@ -1951,7 +1972,7 @@ async function getSecretRedactionError(jobId, step) {
/**
* Find the failed agent step using Actions metadata only, never agent-generated log text.
* @param {{startupFailure?: boolean}} [options] - Select the first failed step only for a validated collector startup failure.
* @returns {Promise<{failingStep: string, agentConclusion?: string, attributionUnavailable?: string, hasNoJobs?: boolean, secretRedactionFailed?: boolean, stepError?: string, cancelledWithoutTimeout?: boolean, isTimedOut?: boolean}>}
* @returns {Promise<{failingStep: string, agentConclusion?: string, attributionUnavailable?: string, hasNoJobs?: boolean, secretRedactionFailed?: boolean, stepError?: string, cancelledWithoutTimeout?: boolean, isTimedOut?: boolean, preAgentFailure?: boolean, checkoutRef?: string}>}
*/
async function getFailedAgentDiagnostics({ startupFailure = false } = {}) {
let jobs;
Expand Down Expand Up @@ -2012,11 +2033,24 @@ async function getFailedAgentDiagnostics({ startupFailure = false } = {}) {
if (agentConclusion !== "failure" && agentConclusion !== "timed_out" && !isCancelledTimeout) {
return { failingStep: "", agentConclusion };
}
const steps = agentJob.steps?.slice();
const steps = agentJob.steps?.slice() || [];
const setupBoundary = steps.findIndex(step => isGatewayStartupStep(step) || isExecuteAgentStep(step));
let setupFailure;
if (setupBoundary >= 0) {
const boundaryStep = steps[setupBoundary];
const executionSteps = steps.filter(isExecuteAgentStep);
const boundaryFailed = ["skipped", "failure", "timed_out"].includes(boundaryStep.conclusion);
const agentNeverRan = executionSteps.every(step => step.conclusion === "skipped");
const boundaryIndicatesPreAgentFailure = startupFailure || boundaryStep.conclusion === "skipped" || executionSteps.some(step => step.conclusion === "skipped");
if (boundaryFailed && boundaryIndicatesPreAgentFailure && agentNeverRan) {
const setupStepsEnd = isGatewayStartupStep(boundaryStep) ? setupBoundary + 1 : setupBoundary;
setupFailure = steps.slice(0, setupStepsEnd).find(step => isFailedStep(step) && isSetupFailureStep(step));
}
}
if (!startupFailure) steps?.reverse();
const redactionStep = steps?.find(step => step.name === "Redact secrets in logs" && ["failure", "timed_out"].includes(step.conclusion));
const redactionStep = steps?.find(step => step.name === "Redact secrets in logs" && isFailedStep(step));
// Failed redaction blocks safe diagnostics even after an agent crash; startup keeps priority.
const failedStep = (!startupFailure && redactionStep) || steps?.find(step => (["failure", "timed_out"].includes(step.conclusion) || (isCancelledTimeout && step.conclusion === "cancelled")) && typeof step.name === "string");
const failedStep = (!startupFailure && redactionStep) || setupFailure || steps?.find(step => (isFailedStep(step) || (isCancelledTimeout && step.conclusion === "cancelled")) && typeof step.name === "string");
if (!failedStep) {
return { failingStep: "", agentConclusion, ...(isCancelledTimeout ? { isTimedOut: true } : {}), attributionUnavailable: "The failed agent job had no failed step metadata." };
}
Expand All @@ -2028,17 +2062,32 @@ async function getFailedAgentDiagnostics({ startupFailure = false } = {}) {
return {
failingStep,
agentConclusion,
...(failedStep === setupFailure ? { preAgentFailure: true } : {}),
...(failedStep === setupFailure && failedStep.name === "Checkout repository (gh-aw default)" && process.env.GH_AW_DEFAULT_CHECKOUT_USES_TRIGGER_REF === "true" && /^refs\/pull\/[1-9]\d*\/merge$/.test(context.ref || "")
? { checkoutRef: context.ref }
: {}),
...(isCancelledTimeout ? { isTimedOut: true } : {}),
...(failedStep === redactionStep ? { secretRedactionFailed: true, stepError: await getSecretRedactionError(agentJob.id, redactionStep) } : {}),
};
}

/**
* Add step-level context to generic agent and infrastructure failures.
* @param {{failureCategories: string[], failingStep: string, agentConclusion?: string, attributionUnavailable?: string, stepError?: string, engineFailureContext: ReportBlock[], checkoutError?: string, items?: Array<any>}} options
* @param {{failureCategories: string[], failingStep: string, agentConclusion?: string, attributionUnavailable?: string, stepError?: string, engineFailureContext: ReportBlock[], checkoutError?: string, items?: Array<any>, preAgentFailure?: boolean, checkoutRef?: string}} options
* @returns {ReportBlock[]}
*/
function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentConclusion = "", attributionUnavailable = "", stepError = "", engineFailureContext, checkoutError = "", items = [] }) {
function buildFailureDiagnosticsContext({
failureCategories,
failingStep,
agentConclusion = "",
attributionUnavailable = "",
stepError = "",
engineFailureContext,
checkoutError = "",
items = [],
preAgentFailure = false,
checkoutRef = "",
}) {
const checkoutContext = checkoutError
? [
nodes.heading("Runtime Checkout Rejected"),
Expand All @@ -2051,7 +2100,8 @@ function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentC
]
: [];
const infrastructureMessages = items.filter(item => item?.type === "report_incomplete" && item.reason === "infrastructure_error");
const isGenericFailure = Boolean(stepError) || failureCategories.includes("agent_failure") || failureCategories.includes("engine_driver_failure") || failureCategories.includes("agent_startup_failure") || infrastructureMessages.length > 0;
const isGenericFailure =
preAgentFailure || Boolean(stepError) || failureCategories.includes("agent_failure") || failureCategories.includes("engine_driver_failure") || failureCategories.includes("agent_startup_failure") || infrastructureMessages.length > 0;
const hasModelEndpointMismatch = failureCategories.includes("model_endpoint_mismatch");
if (!isGenericFailure && !hasModelEndpointMismatch) {
return checkoutContext;
Expand All @@ -2074,6 +2124,14 @@ function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentC
metadata.push(nodes.strong([nodes.text("Failing step:")]), nodes.text(" Unavailable"));
}
context.push(nodes.paragraph(metadata));
if (preAgentFailure) {
context.push(nodes.paragraph([nodes.strong([nodes.text("Pre-agent setup failure:")]), nodes.text(" The agent did not start. Inspect the failing setup step in the linked run.")]));
if (checkoutRef) {
context.push(
nodes.paragraph([nodes.text("Requested pull-request merge ref: "), nodes.code(checkoutRef), nodes.text(". Verify that this ref is still available and the pull request is mergeable; checkout did not fall back to the head branch.")])
);
}
}
if (stepError) {
context.push(...renderErrorDetails(stepError));
} else if (engineFailureContext.length) {
Expand Down
94 changes: 94 additions & 0 deletions actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,100 @@ describe("handle_agent_failure", () => {
});

describe("failure diagnostics", () => {
it("reports a failed merge-ref checkout as pre-agent with the step and ref", async () => {
process.env.GH_AW_DEFAULT_CHECKOUT_USES_TRIGGER_REF = "true";
global.context.ref = "refs/pull/42/merge";
global.github = {
paginate: vi.fn().mockResolvedValue([
{
name: "agent",
conclusion: "failure",
steps: [
{ name: "Checkout repository (gh-aw default)", conclusion: "failure" },
{ name: "Start MCP Gateway", conclusion: "skipped" },
{ name: "Execute GitHub Copilot CLI", conclusion: "skipped" },
{ name: "Upload logs", conclusion: "failure" },
],
},
]),
rest: { actions: { listJobsForWorkflowRun: vi.fn() } },
};

const diagnostics = await getFailedAgentDiagnostics();
expect(diagnostics).toEqual({
failingStep: "Checkout repository (gh-aw default)",
agentConclusion: "failure",
preAgentFailure: true,
checkoutRef: "refs/pull/42/merge",
});
const rendered = buildFailureDiagnosticsContext({ failureCategories: ["agent_failure"], engineFailureContext: "", ...diagnostics });
expect(rendered).toContain("Pre-agent setup failure:");
expect(rendered).toContain("The agent did not start.");
expect(rendered).toContain("`Checkout repository (gh-aw default)`");
expect(rendered).toContain("`refs/pull/42/merge`");
expect(rendered).toContain("Verify that this ref is still available");
});

it.each(["Start MCP Gateway", "Checkout org/other"])("attributes pre-agent setup failure to %s without assuming the checkout ref", async failingStep => {
global.context.ref = "refs/pull/42/merge";
global.github = {
paginate: vi.fn().mockResolvedValue([
{
name: "agent",
conclusion: "failure",
steps: [
{ name: failingStep, conclusion: "failure" },
{ name: "Execute Codex CLI", conclusion: "skipped" },
],
},
]),
rest: { actions: { listJobsForWorkflowRun: vi.fn() } },
};
await expect(getFailedAgentDiagnostics()).resolves.toEqual({ failingStep, agentConclusion: "failure", preAgentFailure: true });
});

it("ignores an unrelated Execute step before the generated checkout and execution steps", async () => {
global.github = {
paginate: vi.fn().mockResolvedValue([
{
name: "agent",
conclusion: "failure",
steps: [
{ name: "Execute migrations", conclusion: "success" },
{ name: "Checkout repository (gh-aw default)", conclusion: "failure" },
{ name: "Start MCP Gateway", conclusion: "skipped" },
{ name: "Execute GitHub Copilot CLI", conclusion: "skipped" },
],
},
]),
rest: { actions: { listJobsForWorkflowRun: vi.fn() } },
};

await expect(getFailedAgentDiagnostics()).resolves.toEqual({
failingStep: "Checkout repository (gh-aw default)",
agentConclusion: "failure",
preAgentFailure: true,
});
});

it("does not classify a checkout after agent execution as pre-agent", async () => {
global.github = {
paginate: vi.fn().mockResolvedValue([
{
name: "agent",
conclusion: "failure",
steps: [
{ name: "Start MCP Gateway", conclusion: "success" },
{ name: "Execute Claude Code CLI", conclusion: "success" },
{ name: "Checkout org/other", conclusion: "failure" },
],
},
]),
rest: { actions: { listJobsForWorkflowRun: vi.fn() } },
};
await expect(getFailedAgentDiagnostics()).resolves.toEqual({ failingStep: "Checkout org/other", agentConclusion: "failure" });
});

it("includes the runtime checkout rejection and remediation in failure diagnostics", () => {
const rendered = buildFailureDiagnosticsContext({
failureCategories: ["agent_failure"],
Expand Down
Loading
Loading