Skip to content
Closed
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
30 changes: 30 additions & 0 deletions actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* 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.
* - Connection-refused API errors are retried as fresh runs because no resumable model turn
* reached the provider.
* - 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 @@ -82,6 +84,9 @@ const RATE_LIMIT_ERROR_PATTERN = /rate_limit_error|429 Too Many Requests|"api_er
// session state and reproduce the identical error.
const INVALID_JSON_BODY_ERROR_PATTERN = /request body is not valid JSON/i;

// Pattern emitted by Claude Code when its connection to the API proxy is refused.
const CONNECTION_REFUSED_ERROR_PATTERN = /API Error: Connection refused\b|\bConnectionRefused\b/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 @@ -190,6 +195,15 @@ function isInvalidJsonBodyError(output) {
return INVALID_JSON_BODY_ERROR_PATTERN.test(output);
}

/**
* Determines if the collected output contains a connection-refused API error.
* @param {string} output - Collected stdout+stderr from the process
* @returns {boolean}
*/
function isConnectionRefusedError(output) {
return CONNECTION_REFUSED_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 @@ -482,6 +496,7 @@ async function main() {
const isNoDeferredMarker = isNoDeferredMarkerError(result.output);
const isInvalidModel = isInvalidModelError(result.output);
const isInvalidJsonBody = isInvalidJsonBodyError(result.output);
const isConnectionRefused = isConnectionRefusedError(result.output);
const permissionDeniedCount = countPermissionDeniedIssues(result.output);
const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output);
log(
Expand All @@ -494,6 +509,7 @@ async function main() {
` isNoDeferredMarkerError=${isNoDeferredMarker}` +
` isInvalidModelError=${isInvalidModel}` +
` isInvalidJsonBodyError=${isInvalidJsonBody}` +
` isConnectionRefusedError=${isConnectionRefused}` +
` permissionDeniedCount=${permissionDeniedCount}` +
` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` +
` hasOutput=${result.hasOutput}` +
Expand Down Expand Up @@ -598,6 +614,19 @@ async function main() {
break;
}

// A refused API connection can produce structured CLI output without completing a
// model turn. There is no useful session state to resume, so retry the original prompt
// as a fresh run instead of spending an attempt on --continue.
if (isConnectionRefused) {
if (attempt < maxRetries && result.hasOutput) {
useContinueOnRetry = false;
log(`attempt ${attempt + 1}: connection-refused API transport error — retrying as fresh run (attempt ${attempt + 2}/${maxRetries + 1})`);
continue;
}
log(`attempt ${attempt + 1}: connection-refused API transport error — retry budget exhausted`);
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) {
Expand Down Expand Up @@ -664,6 +693,7 @@ if (typeof module !== "undefined" && module.exports) {
isNoDeferredMarkerError,
isInvalidModelError,
isInvalidJsonBodyError,
isConnectionRefusedError,
isSignalTerminationExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
Expand Down
42 changes: 42 additions & 0 deletions actions/setup/js/claude_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
isNoDeferredMarkerError,
isInvalidModelError,
isInvalidJsonBodyError,
isConnectionRefusedError,
isSignalTerminationExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
Expand Down Expand Up @@ -317,6 +318,16 @@ describe("claude_harness.cjs", () => {
});
});

describe("isConnectionRefusedError", () => {
it("detects Claude API connection-refused output", () => {
expect(isConnectionRefusedError("API Error: Connection refused — a firewall or proxy may be blocking it (ConnectionRefused)")).toBe(true);
});

it("does not match unrelated API errors", () => {
expect(isConnectionRefusedError("API Error: 429 Too Many Requests")).toBe(false);
});
});

describe("permission-denied classification helpers", () => {
it("counts repeated permission-denied signals", () => {
const output = "permission denied\nEACCES: permission denied\npermissions denied";
Expand Down Expand Up @@ -539,6 +550,37 @@ process.exit(0);
expect(result.stderr).toContain("failure_reason=cancelled_or_timed_out");
}, 30000);

it("uses a fresh retry after a connection-refused API error", () => {
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('{"type":"result","is_error":true,"result":"API Error: Connection refused (ConnectionRefused)"}\\n');
process.exit(1);
}

if (args.includes("--continue")) {
process.stderr.write("connection-refused retry unexpectedly used --continue\\n");
process.exit(9);
}
process.stdout.write("fresh retry succeeded\\n");
process.exit(0);
`;
const { result, calls } = runHarnessWithStub({
stubScript,
extraEnv: { GH_AW_HARNESS_INITIAL_DELAY_MS: "1" },
});

expect(result.status, result.stderr).toBe(0);
expect(calls.map(call => call.args.includes("--continue"))).toEqual([false, false]);
expect(calls[1].args).toContain("fix the bug");
expect(result.stderr).toContain("connection-refused API transport error");
});

it("retries one no-output startup failure as a fresh run by default", () => {
const stubScript = `
const fs = require("fs");
Expand Down