Skip to content

Commit aa98660

Browse files
Copilotpelikhan
andauthored
Stop Codex resume retries on deterministic request-body rejection
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent e083a23 commit aa98660

2 files changed

Lines changed: 75 additions & 10 deletions

File tree

‎actions/setup/js/codex_harness.cjs‎

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

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

9596
// Codex's `turn.failed` event nests the actual provider error as a JSON string inside
9697
// `error.message` (sometimes doubly-nested, e.g. `error.message` -> `{"error": {...}}`).
@@ -240,21 +241,32 @@ function isInvalidModelError(output) {
240241

241242
/**
242243
* Determines if Codex emitted a `turn.failed` provider event containing a deterministic
243-
* request-validation failure (HTTP 400 `invalid_request_error`). Such schema-level rejections
244-
* can never succeed on a fresh run with the same input, so they are treated as terminal.
244+
* request-validation failure (HTTP 400 `invalid_request_error` or `invalid_request_body`).
245+
* Such schema-level rejections cannot succeed by replaying the same request or session.
245246
* Tokens in agent transcripts and tool responses are intentionally ignored.
246247
* @param {string} output - Collected stdout+stderr from the process
247248
* @returns {boolean}
248249
*/
249250
function isInvalidRequestError(output) {
250-
return output.split(/\r?\n/).some(line => {
251+
return extractInvalidRequestErrorCode(output) !== null;
252+
}
253+
254+
/**
255+
* @param {string} output
256+
* @returns {string | null}
257+
*/
258+
function extractInvalidRequestErrorCode(output) {
259+
for (const line of output.split(/\r?\n/)) {
251260
try {
252261
const event = JSON.parse(line);
253-
return event?.type === "turn.failed" && event.error && INVALID_REQUEST_ERROR_PATTERN.test(JSON.stringify(event.error));
262+
if (event?.type !== "turn.failed" || !event.error) continue;
263+
const match = JSON.stringify(event.error).match(INVALID_REQUEST_ERROR_PATTERN);
264+
if (match) return match[0].toLowerCase();
254265
} catch {
255-
return false;
266+
// Ignore non-JSON diagnostic lines.
256267
}
257-
});
268+
}
269+
return null;
258270
}
259271

260272
/**
@@ -911,7 +923,8 @@ async function main() {
911923
const isServer = isServerError(result.output);
912924
const isInvalidModel = isInvalidModelError(result.output);
913925
const isUnsupportedModelTools = isUnsupportedModelToolsError(result.output);
914-
const isInvalidRequest = isInvalidRequestError(result.output);
926+
const invalidRequestErrorCode = extractInvalidRequestErrorCode(result.output);
927+
const isInvalidRequest = invalidRequestErrorCode !== null;
915928
const permissionDeniedCount = countPermissionDeniedIssues(result.output);
916929
const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output);
917930
log(
@@ -1004,7 +1017,10 @@ async function main() {
10041017
}
10051018

10061019
if (isInvalidRequest) {
1007-
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)`);
1020+
emitInfrastructureIncomplete(`Codex request-body rejection (${invalidRequestErrorCode}): the provider rejected the request payload; not retrying because replaying the same request or session would fail identically.`, {
1021+
logger: log,
1022+
});
1023+
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)`);
10081024
return { action: "stop" };
10091025
}
10101026

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,46 @@ process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1
151151
expect(calls[1].stdin).not.toBe(calls[0].stdin);
152152
});
153153

154+
it.each(["stdout", "stderr"])("stops after an invalid_request_body resume rejection on %s and reports the cause", stream => {
155+
const id = "0199a213-81c0-7800-8aa1-bbab2a035a53";
156+
const dir = makeHarnessTempDir("invalid-request-body-");
157+
const safeOutputsPath = path.join(dir, "safe-outputs.jsonl");
158+
const safeOutputsCLI = path.join(dir, "safeoutputs.cjs");
159+
fs.writeFileSync(
160+
safeOutputsCLI,
161+
`#!${process.execPath}
162+
const fs = require("fs");
163+
const args = process.argv.slice(2);
164+
const item = { type: args[0] };
165+
for (let i = 1; i < args.length; i += 2) item[args[i].slice(2)] = args[i + 1];
166+
fs.appendFileSync(process.env.GH_AW_SAFE_OUTPUTS, JSON.stringify(item) + "\\n");
167+
`
168+
);
169+
fs.chmodSync(safeOutputsCLI, 0o700);
170+
const { result, calls } = runHarnessFixture(
171+
`
172+
process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"${id}"})+"\\n");
173+
const resumed = process.argv.includes("resume");
174+
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";
175+
process.${stream}.write(JSON.stringify({type:"turn.failed",error:{message}})+"\\n");
176+
process.exit(1);`,
177+
{ env: { GH_AW_HARNESS_MAX_RETRIES: "3", GH_AW_SAFE_OUTPUTS: safeOutputsPath, GH_AW_SAFEOUTPUTS_CLI: safeOutputsCLI } }
178+
);
179+
expect(result.status).toBe(1);
180+
expect(calls).toHaveLength(2);
181+
expect(calls[1].args).toEqual(["exec", "--json", "resume", id, "-"]);
182+
expect(result.stderr).toContain("invalid_request_body (HTTP 400) — not retrying");
183+
const items = fs
184+
.readFileSync(safeOutputsPath, "utf8")
185+
.trim()
186+
.split("\n")
187+
.map(line => JSON.parse(line));
188+
expect(items).toHaveLength(1);
189+
expect(items[0]).toMatchObject({ type: "report_incomplete", reason: "infrastructure_error" });
190+
expect(items[0].details).toContain("request-body rejection (invalid_request_body)");
191+
expect(items[0].details).toContain("not retrying");
192+
});
193+
154194
it("does not select a session when ephemeral mode prevents persistence", () => {
155195
expect(buildCodexResumeArgs(["exec", "--ephemeral", "-"], "0199a213-81c0-7800-8aa1-bbab2a035a53")).toBeNull();
156196
expect(buildCodexResumeArgs(["exec", "-"], "not-an-id")).toBeNull();
@@ -785,11 +825,20 @@ env_key = "OPENAI_API_KEY"
785825
expect(isInvalidRequestError('{"type":"turn.failed","error":{"type":"invalid_request_error"}}')).toBe(true);
786826
});
787827

828+
it("returns true for the observed nested invalid_request_body item-ID rejection", () => {
829+
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"}}`;
830+
expect(isInvalidRequestError(output)).toBe(true);
831+
expect(isInvalidRequestError('{"type":"turn.failed","error":{"code":"invalid_request_body"}}')).toBe(true);
832+
});
833+
788834
it("returns false for unrelated errors and tool transcript content", () => {
789835
expect(isInvalidRequestError("rate_limit_exceeded")).toBe(false);
790836
expect(isInvalidRequestError("500 Internal Server Error")).toBe(false);
791837
expect(isInvalidRequestError("GitHub API responded with 400 Bad Request")).toBe(false);
792838
expect(isInvalidRequestError('{"type":"item.completed","item":{"type":"tool_call_output","output":"invalid_request_error"}}')).toBe(false);
839+
expect(isInvalidRequestError('{"type":"item.completed","item":{"type":"agent_message","text":"invalid_request_body"}}')).toBe(false);
840+
expect(isInvalidRequestError('{"type":"item.completed","item":{"type":"tool_call_output","output":"invalid_request_body"}}')).toBe(false);
841+
expect(isInvalidRequestError("invalid_request_body")).toBe(false);
793842
expect(isInvalidRequestError("")).toBe(false);
794843
});
795844
});

0 commit comments

Comments
 (0)