diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 66fc3786996..03cd43f0a63 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -14,6 +14,10 @@ * - Overloaded API errors (HTTP 529 / "overloaded_error") and rate-limit errors (HTTP 429 / * "rate_limit_error") are well-known transient failure modes and are logged explicitly, but * any partial-execution failure is retried — not just those specific errors. + * - "The request body is not valid JSON" (HTTP 400) is a transport-level serialization bug, + * observed immediately after a `permission_denied` tool-result on a compound Bash command. + * It is retried as a fresh run (not `--continue`, which is permanently disabled for the rest + * of the driver invocation) since resuming would resend the same corrupted session state. * - If the process produced no output (failed to start / auth error before any work), the * driver does not retry because there is nothing to resume. * - On a `--continue` retry the initial prompt is omitted: Claude Code resumes the session @@ -68,6 +72,16 @@ const OVERLOADED_ERROR_PATTERN = /overloaded_error|"overloaded"/i; // - human-readable message text ("rate limit") const RATE_LIMIT_ERROR_PATTERN = /rate_limit_error|429 Too Many Requests|"api_error_status"\s*:\s*429|request rejected \(429\)|rate limit/i; +// Pattern to detect the transport-level "invalid JSON request body" error. +// Observed after a `permission_denied` tool-result on a compound Bash command: the +// CLI appears to re-serialize the conversation for the next turn incorrectly, +// producing an empty/malformed body that the Anthropic API rejects with HTTP 400 +// before any model logic runs. This is a serialization glitch, not a real +// application-level 400 from the model, so it should be retried — but as a fresh +// run rather than --continue, since resuming would resend the same corrupted +// session state and reproduce the identical error. +const INVALID_JSON_BODY_ERROR_PATTERN = /request body is not valid JSON/i; + // Pattern to detect a clean max-turns exit from Claude Code. // Claude Code emits a JSON result object with "subtype":"error_max_turns" when the // session ends because the turn limit was reached. This is a deterministic terminal @@ -162,6 +176,20 @@ function isMaxTurnsExit(output) { return MAX_TURNS_EXIT_PATTERN.test(output); } +/** + * Determines if the collected output contains the transport-level "invalid JSON + * request body" error (HTTP 400 "The request body is not valid JSON"). This has + * been observed immediately following a `permission_denied` tool-result on a + * compound Bash command, where the CLI appears to re-serialize the conversation + * incorrectly for the next turn. It is a serialization bug, not a genuine + * application-level 400 from the model. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function isInvalidJsonBodyError(output) { + return INVALID_JSON_BODY_ERROR_PATTERN.test(output); +} + /** * Determines if the collected output contains a "no deferred tool marker" error. * This occurs when Claude Code is invoked with --continue but the session was never @@ -453,6 +481,7 @@ async function main() { const isMaxTurns = isMaxTurnsExit(result.output); const isNoDeferredMarker = isNoDeferredMarkerError(result.output); const isInvalidModel = isInvalidModelError(result.output); + const isInvalidJsonBody = isInvalidJsonBodyError(result.output); const permissionDeniedCount = countPermissionDeniedIssues(result.output); const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output); log( @@ -464,6 +493,7 @@ async function main() { ` isMaxTurnsExit=${isMaxTurns}` + ` isNoDeferredMarkerError=${isNoDeferredMarker}` + ` isInvalidModelError=${isInvalidModel}` + + ` isInvalidJsonBodyError=${isInvalidJsonBody}` + ` permissionDeniedCount=${permissionDeniedCount}` + ` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` + ` hasOutput=${result.hasOutput}` + @@ -552,6 +582,22 @@ async function main() { break; } + // "The request body is not valid JSON" is a transport-level serialization bug, + // observed immediately after a permission_denied tool-result on a compound Bash + // command. Retrying with --continue would resend the same corrupted on-disk + // session state and reproduce the identical error, so force a fresh run and + // permanently disable --continue for the remainder of this driver invocation. + if (isInvalidJsonBody) { + if (attempt < maxRetries && result.hasOutput) { + useContinueOnRetry = false; + continueDisabledPermanently = true; + log(`attempt ${attempt + 1}: invalid JSON request body (transport-level serialization bug, likely following a permission_denied) — retrying as fresh run (--continue disabled permanently, attempt ${attempt + 2}/${maxRetries + 1})`); + continue; + } + log(`attempt ${attempt + 1}: invalid JSON request body — not retriable via --continue (failure_reason=harness_retry_path_invalid)`); + break; + } + // Retry when the session was partially executed (has output). // Use --continue so Claude Code can resume from its saved session state. if (attempt < maxRetries && result.hasOutput) { @@ -617,6 +663,7 @@ if (typeof module !== "undefined" && module.exports) { isMaxTurnsExit, isNoDeferredMarkerError, isInvalidModelError, + isInvalidJsonBodyError, isSignalTerminationExitCode, shouldRetryWithContinue, countPermissionDeniedIssues, diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index 9b9a79d595d..ca740c3f3b2 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -14,6 +14,7 @@ const { isMaxTurnsExit, isNoDeferredMarkerError, isInvalidModelError, + isInvalidJsonBodyError, isSignalTerminationExitCode, shouldRetryWithContinue, countPermissionDeniedIssues, @@ -269,6 +270,41 @@ describe("claude_harness.cjs", () => { }); }); + describe("isInvalidJsonBodyError", () => { + it("returns true for the canonical Anthropic 400 invalid-JSON message", () => { + const output = "API Error: 400 The request body is not valid JSON: unexpected character: line 1 column 1 (char 0)"; + expect(isInvalidJsonBodyError(output)).toBe(true); + }); + + it("returns true for mixed-case variant", () => { + expect(isInvalidJsonBodyError("the REQUEST BODY IS NOT VALID json")).toBe(true); + }); + + it("returns true when the error appears inside a larger log block following a permission_denied", () => { + const output = + '{"type":"result","subtype":"permission_denied","decision_reason_type":"subcommandResults"}\n' + + "[claude-harness] 2026-08-10T12:33:00.000Z attempt 4 failed: exitCode=1\n" + + '{"type":"text","text":"API Error: 400 The request body is not valid JSON: unexpected character: line 1 column 1 (char 0)"}'; + expect(isInvalidJsonBodyError(output)).toBe(true); + }); + + it("returns false for an overloaded_error output", () => { + expect(isInvalidJsonBodyError('{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}')).toBe(false); + }); + + it("returns false for a rate_limit_error output", () => { + expect(isInvalidJsonBodyError('{"type":"result","subtype":"success","is_error":true,"api_error_status":429}')).toBe(false); + }); + + it("returns false for an empty string", () => { + expect(isInvalidJsonBodyError("")).toBe(false); + }); + + it("returns false for a successful result output", () => { + expect(isInvalidJsonBodyError('{"type":"result","subtype":"success","is_error":false}')).toBe(false); + }); + }); + describe("isSignalTerminationExitCode", () => { it("returns true for SIGKILL/SIGTERM-style exit codes", () => { expect(isSignalTerminationExitCode(137)).toBe(true); @@ -403,6 +439,43 @@ process.exit(0); expect(result.stderr).toContain("failure_reason=harness_retry_path_invalid"); }, 50000); + it("uses a fresh retry and permanently disables --continue after a 400 invalid-JSON-body error on --continue", () => { + const stubScript = ` +const fs = require("fs"); +const callsPath = process.env.CLAUDE_HARNESS_STUB_CALLS; +const args = process.argv.slice(2); +const priorCalls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").trim().split("\\n").filter(Boolean).length : 0; +fs.appendFileSync(callsPath, JSON.stringify({ args }) + "\\n", "utf8"); + +if (priorCalls === 0) { + process.stdout.write("partial execution before retry\\n"); + process.exit(1); +} + +if (priorCalls === 1) { + if (!args.includes("--continue")) { + process.stderr.write("expected --continue on first retry\\n"); + process.exit(9); + } + process.stderr.write('{"type":"result","subtype":"error","is_error":true,"result":"400 The request body is not valid JSON"}\\n'); + process.exit(1); +} + +if (args.includes("--continue")) { + process.stderr.write("fresh retry unexpectedly used --continue\\n"); + process.exit(9); +} +process.stdout.write("fresh retry succeeded\\n"); +process.exit(0); +`; + const { result, calls } = runHarnessWithStub({ stubScript }); + + expect(result.status, result.stderr).toBe(0); + expect(calls.map(call => call.args.includes("--continue"))).toEqual([false, true, false]); + expect(calls[2].args).toContain("fix the bug"); + expect(result.stderr).toContain("invalid JSON request body"); + }, 50000); + it("strips user-supplied --continue on fresh retry after invalid continue-path detection", () => { const stubScript = ` const fs = require("fs");