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
39 changes: 38 additions & 1 deletion actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ const OVERLOADED_ERROR_PATTERN = /overloaded_error|"overloaded"/i;
// Pattern to detect Anthropic rate-limit errors (HTTP 429).
const RATE_LIMIT_ERROR_PATTERN = /rate_limit_error|429 Too Many Requests/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
// condition — --continue cannot recover it because no deferred tool marker was written.
const MAX_TURNS_EXIT_PATTERN = /"subtype"\s*:\s*"error_max_turns"/;

/**
* Emit a timestamped diagnostic log line to stderr.
* All driver messages are prefixed with "[claude-harness]" so they are easy to
Expand Down Expand Up @@ -92,6 +98,18 @@ function isRateLimitError(output) {
return RATE_LIMIT_ERROR_PATTERN.test(output);
}

/**
* Determines if the collected output signals a clean max-turns exit.
* When Claude Code hits its turn limit it emits a result object with
* "subtype":"error_max_turns". This is not a transient error — retrying
* with --continue will always fail because no deferred tool marker was written.
* @param {string} output - Collected stdout+stderr from the process
* @returns {boolean}
*/
function isMaxTurnsExit(output) {
return MAX_TURNS_EXIT_PATTERN.test(output);
}

/**
* Sleep for a specified duration
* @param {number} ms - Duration in milliseconds
Expand Down Expand Up @@ -341,7 +359,25 @@ async function main() {

const isOverloaded = isOverloadedError(result.output);
const isRateLimit = isRateLimitError(result.output);
log(`attempt ${attempt + 1} failed:` + ` exitCode=${result.exitCode}` + ` isOverloadedError=${isOverloaded}` + ` isRateLimitError=${isRateLimit}` + ` hasOutput=${result.hasOutput}` + ` retriesRemaining=${MAX_RETRIES - attempt}`);
const isMaxTurns = isMaxTurnsExit(result.output);
log(
`attempt ${attempt + 1} failed:` +
` exitCode=${result.exitCode}` +
` isOverloadedError=${isOverloaded}` +
` isRateLimitError=${isRateLimit}` +
` isMaxTurnsExit=${isMaxTurns}` +
` hasOutput=${result.hasOutput}` +
` retriesRemaining=${MAX_RETRIES - attempt}`
);

// max_turns is a deterministic terminal condition: the session ended cleanly after
// exhausting the allowed number of turns. --continue cannot resume it because no
// deferred tool marker was written. Retrying would immediately fail with "No deferred
// tool marker found", wasting time and masking the real exit reason.
if (isMaxTurns) {
log(`attempt ${attempt + 1}: max_turns exit — not retriable via --continue`);
break;
}

// Retry when the session was partially executed (has output).
// Use --continue so Claude Code can resume from its saved session state.
Expand Down Expand Up @@ -372,6 +408,7 @@ if (typeof module !== "undefined" && module.exports) {
module.exports = {
resolveClaudePromptFileArgs,
stripPromptFileArgs,
isMaxTurnsExit,
};
}

Expand Down
29 changes: 28 additions & 1 deletion actions/setup/js/claude_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import os from "os";
import path from "path";

const require = createRequire(import.meta.url);
const { resolveClaudePromptFileArgs, stripPromptFileArgs } = require("./claude_harness.cjs");
const { resolveClaudePromptFileArgs, stripPromptFileArgs, isMaxTurnsExit } = require("./claude_harness.cjs");

describe("claude_harness.cjs", () => {
describe("resolveClaudePromptFileArgs", () => {
Expand Down Expand Up @@ -80,4 +80,31 @@ describe("claude_harness.cjs", () => {
expect(result).toEqual(["--print"]);
});
});

describe("isMaxTurnsExit", () => {
it('returns true for a JSON result with "subtype":"error_max_turns"', () => {
const output = '{"type":"result","subtype":"error_max_turns","is_error":true,"num_turns":13,' + '"terminal_reason":"max_turns","errors":["Reached maximum number of turns (12)"]}';
expect(isMaxTurnsExit(output)).toBe(true);
});

it("returns true when subtype has extra whitespace around the colon", () => {
expect(isMaxTurnsExit('"subtype" : "error_max_turns"')).toBe(true);
});

it("returns false for an overloaded_error output", () => {
expect(isMaxTurnsExit('{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}')).toBe(false);
});

it("returns false for a rate_limit_error output", () => {
expect(isMaxTurnsExit('{"type":"error","error":{"type":"rate_limit_error","message":"429 Too Many Requests"}}')).toBe(false);
});

it("returns false for an empty string", () => {
expect(isMaxTurnsExit("")).toBe(false);
});

it("returns false for a successful result output", () => {
expect(isMaxTurnsExit('{"type":"result","subtype":"success","is_error":false}')).toBe(false);
});
});
});
Loading