Skip to content

Commit 3cd8e42

Browse files
Copilotpelikhangh-aw-bot
authored
Stop Codex resume retries after invalid request-body rejection (#66553)
* Initial plan * Stop Codex resume retries on deterministic request-body rejection Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Export Codex request error code helper Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent 6979d57 commit 3cd8e42

2 files changed

Lines changed: 83 additions & 10 deletions

File tree

‎actions/setup/js/codex_harness.cjs‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,12 @@ const MISSING_API_KEY_PATTERN = /Missing environment variable:\s*`?(?:CODEX_API_
8989
// These are transient infrastructure failures that may resolve on retry.
9090
const SERVER_ERROR_PATTERN = /InternalServerError|ServiceUnavailableError|500 Internal Server Error|503 Service Unavailable/i;
9191

92-
// Pattern to detect deterministic request-validation failures (HTTP 400 `invalid_request_error`)
92+
// Pattern to detect deterministic request-validation failures (HTTP 400 `invalid_request_error`
93+
// or `invalid_request_body`, including invalid replayed item IDs during resume)
9394
// within Codex's outer `turn.failed` event. The provider rejects the serialized request itself
9495
// (e.g. `"code": "empty_array"` on `messages[N].content`), so an identical fresh run produces
9596
// an identical rejection: retrying only re-bills the turns that succeeded before the failure point.
96-
const INVALID_REQUEST_ERROR_PATTERN = /invalid_request_error/i;
97+
const INVALID_REQUEST_ERROR_PATTERN = /invalid_request_(?:error|body)/i;
9798

9899
function resolveMCPServerToolTimeouts(config, runtimeToolTimeoutSeconds) {
99100
const configuredServers = config.defaults?.mcp_servers && typeof config.defaults.mcp_servers === "object" ? config.defaults.mcp_servers : {};
@@ -299,21 +300,32 @@ function isInvalidModelError(output) {
299300

300301
/**
301302
* Determines if Codex emitted a `turn.failed` provider event containing a deterministic
302-
* request-validation failure (HTTP 400 `invalid_request_error`). Such schema-level rejections
303-
* can never succeed on a fresh run with the same input, so they are treated as terminal.
303+
* request-validation failure (HTTP 400 `invalid_request_error` or `invalid_request_body`).
304+
* Such schema-level rejections cannot succeed by replaying the same request or session.
304305
* Tokens in agent transcripts and tool responses are intentionally ignored.
305306
* @param {string} output - Collected stdout+stderr from the process
306307
* @returns {boolean}
307308
*/
308309
function isInvalidRequestError(output) {
309-
return output.split(/\r?\n/).some(line => {
310+
return extractInvalidRequestErrorCode(output) !== null;
311+
}
312+
313+
/**
314+
* @param {string} output
315+
* @returns {string | null}
316+
*/
317+
function extractInvalidRequestErrorCode(output) {
318+
for (const line of output.split(/\r?\n/)) {
310319
try {
311320
const event = JSON.parse(line);
312-
return event?.type === "turn.failed" && event.error && INVALID_REQUEST_ERROR_PATTERN.test(JSON.stringify(event.error));
321+
if (event?.type !== "turn.failed" || !event.error) continue;
322+
const match = JSON.stringify(event.error).match(INVALID_REQUEST_ERROR_PATTERN);
323+
if (match) return match[0].toLowerCase();
313324
} catch {
314-
return false;
325+
// Ignore non-JSON diagnostic lines.
315326
}
316-
});
327+
}
328+
return null;
317329
}
318330

319331
/**
@@ -987,7 +999,8 @@ async function main() {
987999
const isServer = isServerError(result.output);
9881000
const isInvalidModel = isInvalidModelError(result.output);
9891001
const isUnsupportedModelTools = isUnsupportedModelToolsError(result.output);
990-
const isInvalidRequest = isInvalidRequestError(result.output);
1002+
const invalidRequestErrorCode = extractInvalidRequestErrorCode(result.output);
1003+
const isInvalidRequest = invalidRequestErrorCode !== null;
9911004
const permissionDeniedCount = countPermissionDeniedIssues(result.output);
9921005
const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output);
9931006
log(
@@ -1080,7 +1093,10 @@ async function main() {
10801093
}
10811094

10821095
if (isInvalidRequest) {
1083-
log(`attempt ${attempt + 1}: invalid_request_error (HTTP 400) — not retrying (the provider rejected the request payload; an identical fresh run would fail the same way)`);
1096+
emitInfrastructureIncomplete(`Codex request-body rejection (${invalidRequestErrorCode}): the provider rejected the request payload; not retrying because replaying the same request or session would fail identically.`, {
1097+
logger: log,
1098+
});
1099+
log(`attempt ${attempt + 1}: ${invalidRequestErrorCode} (HTTP 400) — not retrying (the provider rejected the request payload; replaying the same request or session would fail the same way)`);
10841100
return { action: "stop" };
10851101
}
10861102

@@ -1156,6 +1172,7 @@ if (typeof module !== "undefined" && module.exports) {
11561172
isInvalidModelError,
11571173
isUnsupportedModelToolsError,
11581174
isInvalidRequestError,
1175+
extractInvalidRequestErrorCode,
11591176
isReconnectExhaustedError,
11601177
countPermissionDeniedIssues,
11611178
hasNumerousPermissionDeniedIssues,

‎actions/setup/js/codex_harness.test.cjs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
isInvalidModelError,
1919
isUnsupportedModelToolsError,
2020
isInvalidRequestError,
21+
extractInvalidRequestErrorCode,
2122
isReconnectExhaustedError,
2223
countPermissionDeniedIssues,
2324
hasNumerousPermissionDeniedIssues,
@@ -208,6 +209,46 @@ process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1
208209
expect(calls[1].stdin).not.toBe(calls[0].stdin);
209210
});
210211

212+
it.each(["stdout", "stderr"])("stops after an invalid_request_body resume rejection on %s and reports the cause", stream => {
213+
const id = "0199a213-81c0-7800-8aa1-bbab2a035a53";
214+
const dir = makeHarnessTempDir("invalid-request-body-");
215+
const safeOutputsPath = path.join(dir, "safe-outputs.jsonl");
216+
const safeOutputsCLI = path.join(dir, "safeoutputs.cjs");
217+
fs.writeFileSync(
218+
safeOutputsCLI,
219+
`#!${process.execPath}
220+
const fs = require("fs");
221+
const args = process.argv.slice(2);
222+
const item = { type: args[0] };
223+
for (let i = 1; i < args.length; i += 2) item[args[i].slice(2)] = args[i + 1];
224+
fs.appendFileSync(process.env.GH_AW_SAFE_OUTPUTS, JSON.stringify(item) + "\\n");
225+
`
226+
);
227+
fs.chmodSync(safeOutputsCLI, 0o700);
228+
const { result, calls } = runHarnessFixture(
229+
`
230+
process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"${id}"})+"\\n");
231+
const resumed = process.argv.includes("resume");
232+
const message = resumed ? JSON.stringify({error:{message:"Invalid 'input[13].id': 'ctco_example'. Expected an ID that begins with 'fc'.",code:"invalid_request_body"}}) : "connection reset";
233+
process.${stream}.write(JSON.stringify({type:"turn.failed",error:{message}})+"\\n");
234+
process.exit(1);`,
235+
{ env: { GH_AW_HARNESS_MAX_RETRIES: "3", GH_AW_SAFE_OUTPUTS: safeOutputsPath, GH_AW_SAFEOUTPUTS_CLI: safeOutputsCLI } }
236+
);
237+
expect(result.status).toBe(1);
238+
expect(calls).toHaveLength(2);
239+
expect(calls[1].args).toEqual(["exec", "--json", "resume", id, "-"]);
240+
expect(result.stderr).toContain("invalid_request_body (HTTP 400) — not retrying");
241+
const items = fs
242+
.readFileSync(safeOutputsPath, "utf8")
243+
.trim()
244+
.split("\n")
245+
.map(line => JSON.parse(line));
246+
expect(items).toHaveLength(1);
247+
expect(items[0]).toMatchObject({ type: "report_incomplete", reason: "infrastructure_error" });
248+
expect(items[0].details).toContain("request-body rejection (invalid_request_body)");
249+
expect(items[0].details).toContain("not retrying");
250+
});
251+
211252
it("does not select a session when ephemeral mode prevents persistence", () => {
212253
expect(buildCodexResumeArgs(["exec", "--ephemeral", "-"], "0199a213-81c0-7800-8aa1-bbab2a035a53")).toBeNull();
213254
expect(buildCodexResumeArgs(["exec", "-"], "not-an-id")).toBeNull();
@@ -842,11 +883,26 @@ env_key = "OPENAI_API_KEY"
842883
expect(isInvalidRequestError('{"type":"turn.failed","error":{"type":"invalid_request_error"}}')).toBe(true);
843884
});
844885

886+
it("extracts the invalid request error code", () => {
887+
expect(extractInvalidRequestErrorCode('{"type":"turn.failed","error":{"type":"invalid_request_error"}}')).toBe("invalid_request_error");
888+
expect(extractInvalidRequestErrorCode('{"type":"turn.failed","error":{"code":"invalid_request_body"}}')).toBe("invalid_request_body");
889+
expect(extractInvalidRequestErrorCode('{"type":"item.completed","item":{"text":"invalid_request_body"}}')).toBeNull();
890+
});
891+
892+
it("returns true for the observed nested invalid_request_body item-ID rejection", () => {
893+
const output = String.raw`{"type":"turn.failed","error":{"message":"{\"error\":{\"message\":\"Invalid 'input[13].id': 'ctco_example'. Expected an ID that begins with 'fc'.\",\"code\":\"invalid_request_body\"}}\n"}}`;
894+
expect(isInvalidRequestError(output)).toBe(true);
895+
expect(isInvalidRequestError('{"type":"turn.failed","error":{"code":"invalid_request_body"}}')).toBe(true);
896+
});
897+
845898
it("returns false for unrelated errors and tool transcript content", () => {
846899
expect(isInvalidRequestError("rate_limit_exceeded")).toBe(false);
847900
expect(isInvalidRequestError("500 Internal Server Error")).toBe(false);
848901
expect(isInvalidRequestError("GitHub API responded with 400 Bad Request")).toBe(false);
849902
expect(isInvalidRequestError('{"type":"item.completed","item":{"type":"tool_call_output","output":"invalid_request_error"}}')).toBe(false);
903+
expect(isInvalidRequestError('{"type":"item.completed","item":{"type":"agent_message","text":"invalid_request_body"}}')).toBe(false);
904+
expect(isInvalidRequestError('{"type":"item.completed","item":{"type":"tool_call_output","output":"invalid_request_body"}}')).toBe(false);
905+
expect(isInvalidRequestError("invalid_request_body")).toBe(false);
850906
expect(isInvalidRequestError("")).toBe(false);
851907
});
852908
});

0 commit comments

Comments
 (0)