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
47 changes: 47 additions & 0 deletions actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -464,6 +493,7 @@ async function main() {
` isMaxTurnsExit=${isMaxTurns}` +
` isNoDeferredMarkerError=${isNoDeferredMarker}` +
` isInvalidModelError=${isInvalidModel}` +
` isInvalidJsonBodyError=${isInvalidJsonBody}` +
` permissionDeniedCount=${permissionDeniedCount}` +
` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` +
` hasOutput=${result.hasOutput}` +
Expand Down Expand Up @@ -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;
}

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.

L574-595: shrink: isNoDeferredMarker and isInvalidJsonBody blocks are identical (same reset + log + continue). if ((isNoDeferredMarker || isInvalidJsonBody) && attempt < maxRetries && result.hasOutput), ~10 fewer lines.


// 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) {
Expand Down Expand Up @@ -617,6 +663,7 @@ if (typeof module !== "undefined" && module.exports) {
isMaxTurnsExit,
isNoDeferredMarkerError,
isInvalidModelError,
isInvalidJsonBodyError,
isSignalTerminationExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
Expand Down
73 changes: 73 additions & 0 deletions actions/setup/js/claude_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
isMaxTurnsExit,
isNoDeferredMarkerError,
isInvalidModelError,
isInvalidJsonBodyError,
isSignalTerminationExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
Loading