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
12 changes: 12 additions & 0 deletions actions/setup/js/collect_ndjson_output.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -383,15 +383,27 @@ async function main() {
}
core.info(`Successfully parsed ${parsedItems.length} valid output items`);
let collectorEmptyOutputCause;
let collectorFailureCause;
let collectorDriverExitCode;
let collectorRetryCount;
let collectorEngineErrorType;
if (!parsedItems.some(item => !["missing_tool", "missing_data"].includes(item.type))) {
const incompleteOutcome = buildEmptyOutputOutcome(errors);
parsedItems.push(incompleteOutcome);
collectorEmptyOutputCause = incompleteOutcome.reason;
collectorFailureCause = incompleteOutcome.failureCause;
collectorDriverExitCode = incompleteOutcome.driverExitCode;
collectorRetryCount = incompleteOutcome.retryCount;
collectorEngineErrorType = incompleteOutcome.engineErrorType;
}
const validatedOutput = {
items: parsedItems,
errors: errors,
...(collectorEmptyOutputCause ? { collectorEmptyOutputCause } : {}),
...(collectorFailureCause ? { collectorFailureCause } : {}),
...(collectorDriverExitCode !== undefined ? { collectorDriverExitCode } : {}),
...(collectorRetryCount !== undefined ? { collectorRetryCount } : {}),
...(collectorEngineErrorType ? { collectorEngineErrorType } : {}),
};
const path = require("path");
const agentOutputFile = path.join(TMP_GH_AW_PATH, AGENT_OUTPUT_FILENAME);
Expand Down
12 changes: 11 additions & 1 deletion actions/setup/js/collect_ndjson_output.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ import { createRequire } from "module";
const _require = createRequire(import.meta.url);
const { AGENT_OUTPUT_FILENAME, TMP_GH_AW_PATH } = _require("./constants.cjs");
const incompleteOutput = JSON.stringify({
items: [{ type: "report_incomplete", reason: "missing_terminal_safe_output", details: "Agent finished without emitting a terminal safe output; task completion could not be confirmed." }],
items: [
{
type: "report_incomplete",
reason: "missing_terminal_safe_output",
failureCause: "prompt_exhaustion",
retryCount: 0,
details: "Agent finished without emitting a terminal safe output; task completion could not be confirmed.\nFailure classification: prompt_exhaustion\nRetry attempts observed: 0",
},
],
errors: [],
collectorEmptyOutputCause: "missing_terminal_safe_output",
collectorFailureCause: "prompt_exhaustion",
collectorRetryCount: 0,
});
describe("collect_ndjson_output.cjs", () => {
let mockCore, collectScript;
Expand Down
71 changes: 69 additions & 2 deletions actions/setup/js/empty_output_outcome.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,41 @@ const EMPTY_OUTPUT_CAUSES = Object.freeze({
invalid_safe_outputs: "produced no valid safe outputs",
missing_terminal_safe_output: "finished without a terminal safe output",
});
const EMPTY_OUTPUT_FAILURE_CAUSES = Object.freeze({
engine_outage: "experienced an engine outage",
request_rejection: "had a request rejected",
prompt_exhaustion: "exhausted its prompt",
});

const PROMPT_EXHAUSTION_ERROR_CATEGORIES = new Set(["effective_tokens_limit_exceeded", "invocation_cap_exceeded"]);
const REQUEST_REJECTION_ERROR_CATEGORIES = new Set([
"ai_credits_rate_limit_error",
"authentication_failed",
"awf_api_proxy_blocking_requests",
"capi_quota_exceeded_error",
"http_400_response_error",
"inference_access_error",
"max_cache_misses_exceeded",
"max_ai_credits_exceeded",
"mcp_policy_error",
"model_not_supported_error",
"shell_expansion_guard_rejected",
]);

/**
* Silence is not evidence of an intentional noop. Preserve runtime diagnostics
* in a first-class incomplete signal when the agent emitted no valid outputs.
* @param {string[]} errors
* @param {string} [rootDir]
* @returns {{type: string, reason: string, details?: string}}
* @returns {{
* type: string,
* reason: string,
* failureCause: string,
* driverExitCode?: number,
* retryCount: number,
* engineErrorType?: string,
* details?: string
* }}
*/
function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") {
const diagnostics = new Set(["Agent finished without emitting a terminal safe output; task completion could not be confirmed.", ...errors]);
Expand Down Expand Up @@ -67,12 +95,20 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") {
} catch {
// Ignore missing CLI audit evidence for workflows using MCP directly.
}
let driverExitCode;
let executionCategories = [];
let executionErrorCodes = [];
for (const event of events) {
if (event.type === "agent.execution" && event.provenance.component === "execution" && event.provenance.phase === "agent" && event.data.exitCode > 0) {
// A CLI parse error itself makes the bridge exit non-zero; preserve that more specific cause.
if (!safeoutputsCliError) reason = "engine_driver_failure";
diagnostics.add(`Driver exit code: ${event.data.exitCode}. The engine driver exited before a terminal safe output was recorded.`);
}
if (event.type === "agent.execution" && event.provenance.component === "execution" && event.provenance.phase === "agent") {
if (Number.isSafeInteger(event.data.exitCode)) driverExitCode = event.data.exitCode;
executionCategories = Array.isArray(event.data.categories) ? event.data.categories : executionCategories;
executionErrorCodes = Array.isArray(event.data.errorCodes) ? event.data.errorCodes : executionErrorCodes;
}
if (event.provenance.component !== "agent") continue;
/** @type {any} */
const data = event.data;
Expand Down Expand Up @@ -122,13 +158,44 @@ function buildEmptyOutputOutcome(errors, rootDir = "/tmp/gh-aw") {
for (const command of extractDeniedCommands(attributedDiagnostics)) diagnostics.add(`Permission denied: ${command}`);
const engineSummary = agentErrorSummaryText(safeStdio);
if (engineSummary) diagnostics.add(engineSummary);
const engineErrorType = engineSummary.match(/\(([a-z][a-z0-9_]*)\)/)?.[1] || "";
if (driverExitCode !== undefined && ![...diagnostics].some(diagnostic => diagnostic.startsWith("Driver exit code:"))) {
diagnostics.add(`Driver exit code: ${driverExitCode}`);
}
const errorCodes = executionErrorCodes.filter(code => Number.isSafeInteger(code) && code >= 400 && code <= 599);
const isPromptExhaustion = executionCategories.some(category => PROMPT_EXHAUSTION_ERROR_CATEGORIES.has(category));
const isRequestRejection = ["invalid_safe_outputs", "safeoutputs_cli_error"].includes(reason) || executionCategories.some(category => REQUEST_REJECTION_ERROR_CATEGORIES.has(category)) || errorCodes.some(code => code >= 400 && code < 500);
const isEngineOutage = reason === "engine_driver_failure" || executionCategories.some(category => ["agentic_engine_timeout", "capi_server_error", "sandbox_runtime_crash"].includes(category)) || errorCodes.some(code => code >= 500);
const failureCause = isPromptExhaustion ? "prompt_exhaustion" : isRequestRejection ? "request_rejection" : isEngineOutage ? "engine_outage" : "prompt_exhaustion";
const retryEvents = events.filter(event => event.type === "claude.api_retry" || (event.type === "system" && event.data.subtype === "api_retry"));
const harnessRetryLines = stdio.split(/\r?\n/).filter(line => /^\[(?:copilot|claude|codex)-harness\].*\bretrying\b/i.test(line));
const harnessRetryCount = harnessRetryLines.length;
const retryCount = Math.max(retryEvents.length, harnessRetryCount);
const retryStatusCodes = [
...new Set(
[...retryEvents.map(event => event.data.status ?? event.data.error_status ?? event.data.code), ...harnessRetryLines.flatMap(line => [...line.matchAll(/\b(?:HTTP\s+|status=)([1-5]\d{2})\b/gi)].map(match => Number(match[1])))].filter(
code => Number.isSafeInteger(code) && code >= 100 && code <= 599
)
),
];
diagnostics.add(`Failure classification: ${failureCause}`);
if (engineErrorType) {
diagnostics.add(`Last engine error type: ${engineErrorType}`);
} else if (reason === "engine_driver_failure") {
diagnostics.add("Last engine error type: unknown");
}
diagnostics.add(`Retry attempts observed: ${retryCount}${retryStatusCodes.length ? ` (HTTP ${retryStatusCodes.join(", HTTP ")})` : ""}`);
const details = [...diagnostics].slice(0, 20).join("\n");
Comment on lines +181 to 188
const sanitized = redactAndBoundDiagnostics(details, { secrets, maskedValues });
return {
type: "report_incomplete",
reason,
failureCause,
...(driverExitCode !== undefined ? { driverExitCode } : {}),
retryCount,
...(engineErrorType ? { engineErrorType } : {}),
...(sanitized ? { details: sanitized } : {}),
};
}

module.exports = { buildEmptyOutputOutcome, EMPTY_OUTPUT_CAUSES };
module.exports = { buildEmptyOutputOutcome, EMPTY_OUTPUT_CAUSES, EMPTY_OUTPUT_FAILURE_CAUSES };
61 changes: 55 additions & 6 deletions actions/setup/js/empty_output_outcome.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,33 @@ describe("empty output outcome", () => {
expect(buildEmptyOutputOutcome([], rootDir)).toEqual({
type: "report_incomplete",
reason: "missing_terminal_safe_output",
details: "Agent finished without emitting a terminal safe output; task completion could not be confirmed.",
failureCause: "prompt_exhaustion",
retryCount: 0,
details: "Agent finished without emitting a terminal safe output; task completion could not be confirmed.\nFailure classification: prompt_exhaustion\nRetry attempts observed: 0",
});
});

it.each([1, 137, 139])("classifies a silent driver exit %s separately from agent behavior", exitCode => {
fs.writeFileSync(path.join(rootDir, "agent_execution_exit_code.txt"), String(exitCode));
const outcome = buildEmptyOutputOutcome([], rootDir);
expect(outcome.reason).toBe("engine_driver_failure");
expect(outcome.failureCause).toBe("engine_outage");
expect(outcome.driverExitCode).toBe(exitCode);
expect(outcome.details).toContain(`Driver exit code: ${exitCode}`);
expect(outcome.details).toContain("Failure classification: engine_outage");
expect(outcome.details).toContain("Last engine error type: unknown");
});

it("includes a successful driver exit code when no terminal output was emitted", () => {
writeEvents([
{
type: "agent.execution",
data: { categories: [], errorCodes: [], errorTypes: [], exitCode: 0 },
},
]);
const outcome = buildEmptyOutputOutcome([], rootDir);
expect(outcome.driverExitCode).toBe(0);
expect(outcome.details).toContain("Driver exit code: 0");
});

it("preserves CLI parse classification when the bridge exits non-zero without copying payloads", () => {
Expand Down Expand Up @@ -132,17 +150,48 @@ describe("empty output outcome", () => {
});

it.each([
["Error: Authentication failed with private-token", "Authentication failed"],
["Access denied by policy settings: private-policy", "Inference access denied"],
["CAPIError: 429 Too Many Requests private-provider", "Provider quota or rate limit exceeded"],
[JSON.stringify({ type: "session.error", data: { message: "Authentication failed", raw: "private-native-payload" } }), "Authentication failed"],
])("preserves classified errors without copying raw diagnostics: %s", (log, summary) => {
["Error: Authentication failed with private-token", "Authentication failed", "request_rejection", "authentication_failed"],
["Access denied by policy settings: private-policy", "Inference access denied", "request_rejection", "inference_access_error"],
["CAPIError: 429 Too Many Requests private-provider", "Provider quota or rate limit exceeded", "request_rejection", "capi_quota_exceeded_error"],
[JSON.stringify({ type: "session.error", data: { message: "Authentication failed", raw: "private-native-payload" } }), "Authentication failed", "request_rejection", "authentication_failed"],
])("preserves classified errors without copying raw diagnostics: %s", (log, summary, failureCause, engineErrorType) => {
fs.writeFileSync(path.join(rootDir, "agent-stdio.log"), log);
const outcome = buildEmptyOutputOutcome([], rootDir);
expect(outcome.details).toContain(summary);
expect(outcome.failureCause).toBe(failureCause);
expect(outcome.engineErrorType).toBe(engineErrorType);
expect(outcome.details).toContain(`Last engine error type: ${engineErrorType}`);
expect(outcome.details).not.toContain("private-");
});

it.each([
["capi_server_error", [502], "engine_outage"],
["http_400_response_error", [400], "request_rejection"],
["invocation_cap_exceeded", [], "prompt_exhaustion"],
])("classifies %s using collector execution metadata", (category, errorCodes, failureCause) => {
writeEvents([
{
type: "agent.execution",
data: { categories: [category], errorCodes, errorTypes: [], exitCode: 1 },
},
]);
const outcome = buildEmptyOutputOutcome([], rootDir);
expect(outcome.failureCause).toBe(failureCause);
expect(outcome.driverExitCode).toBe(1);
expect(outcome.details).toContain(`Failure classification: ${failureCause}`);
});

it("captures retry count and HTTP status without copying retry messages", () => {
fs.writeFileSync(
path.join(rootDir, "agent-stdio.log"),
"[claude-harness] attempt 1: private retry message — retrying due to HTTP 502 (attempt 2/4)\n[claude-harness] attempt 2: private retry message — retrying due to HTTP 503 (attempt 3/4)"
);
const outcome = buildEmptyOutputOutcome([], rootDir);
expect(outcome.retryCount).toBe(2);
expect(outcome.details).toContain("Retry attempts observed: 2 (HTTP 502, HTTP 503)");
expect(outcome.details).not.toContain("private retry message");
});

it("redacts overlapping runtime masks before pattern matching in validation details", () => {
const secret = `sk-proj-${"a".repeat(64)}${"b".repeat(16)}`;
fs.writeFileSync(path.join(rootDir, "agent-stdio.log"), `::add-mask::${secret}\n`);
Expand Down
Loading
Loading