diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 03cd43f0a63..2873046a9b3 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -18,8 +18,9 @@ * 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. + * - Connection-refused failures before the first assistant response are retried as fresh + * runs because there is no session state to resume. + * - Other failures that produce no output use a separate bounded startup retry budget. * - On a `--continue` retry the initial prompt is omitted: Claude Code resumes the session * from its on-disk state rather than re-processing the original instructions. * - Retries use exponential backoff: 5s → 10s → 20s (capped at 60s) by default. @@ -81,6 +82,7 @@ const RATE_LIMIT_ERROR_PATTERN = /rate_limit_error|429 Too Many Requests|"api_er // 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; +const CONNECTION_REFUSED_ERROR_PATTERN = /connection refused|ECONNREFUSED/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 @@ -190,6 +192,25 @@ function isInvalidJsonBodyError(output) { return INVALID_JSON_BODY_ERROR_PATTERN.test(output); } +/** + * Determines if the collected output contains a refused network connection. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function isConnectionRefusedError(output) { + return CONNECTION_REFUSED_ERROR_PATTERN.test(output); +} + +/** + * Determines whether Claude produced an assistant response before failing. + * System initialization and transport-error events do not represent resumable work. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function hasClaudeSessionProgress(output) { + return output.split(/\r?\n/).some(line => /"type"\s*:\s*"assistant"/.test(line)); +} + /** * 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 @@ -425,6 +446,12 @@ async function main() { let useContinueOnRetry = false; let continueDisabledPermanently = false; let startupRetriesUsed = 0; + // Tracks whether the *active session* (the run currently being resumed via --continue) + // has ever produced an assistant response. This must persist across attempts — a later + // --continue attempt can fail during its own startup (e.g. connection refused before it + // emits anything) even though earlier attempts in the same session already made progress. + // Reset only when a genuinely fresh run begins (see below), never on a --continue attempt. + let sessionHasProgress = false; const driverStartTime = Date.now(); // Soft-timeout guard: polled at the top of the retry loop and after each backoff sleep. // It does not preempt a running attempt — if a single invocation runs past the soft @@ -447,6 +474,10 @@ async function main() { currentArgs = [...continueBaseArgs, "--continue"]; } else { currentArgs = attempt === 0 ? initialArgs : freshRetryArgs; + // This attempt starts a brand-new session (either attempt 0, or a fresh + // retry that discards prior on-disk state) — no assistant progress can carry + // forward from any earlier attempt, so reset the tracker. + sessionHasProgress = false; } // Use redacted args for logging when the run carries the prompt text. @@ -482,6 +513,11 @@ async function main() { const isNoDeferredMarker = isNoDeferredMarkerError(result.output); const isInvalidModel = isInvalidModelError(result.output); const isInvalidJsonBody = isInvalidJsonBodyError(result.output); + const isConnectionRefused = isConnectionRefusedError(result.output); + // Accumulate across attempts of the same session: once an assistant response has been + // observed, it stays true for the remainder of this session's --continue attempts, even + // if a later attempt's own output contains nothing but startup/transport errors. + sessionHasProgress = sessionHasProgress || hasClaudeSessionProgress(result.output); const permissionDeniedCount = countPermissionDeniedIssues(result.output); const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output); log( @@ -494,6 +530,8 @@ async function main() { ` isNoDeferredMarkerError=${isNoDeferredMarker}` + ` isInvalidModelError=${isInvalidModel}` + ` isInvalidJsonBodyError=${isInvalidJsonBody}` + + ` isConnectionRefusedError=${isConnectionRefused}` + + ` sessionHasProgress=${sessionHasProgress}` + ` permissionDeniedCount=${permissionDeniedCount}` + ` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` + ` hasOutput=${result.hasOutput}` + @@ -598,6 +636,21 @@ async function main() { break; } + // A refused connection before Claude produces an assistant response means the API + // proxy path was unavailable during startup. There is no session state to resume, so + // retry the original prompt as a fresh run with the normal exponential backoff. + // sessionHasProgress reflects the whole session, not just this attempt's output, so a + // later --continue attempt that fails during its own startup (no assistant line of its + // own) is still correctly treated as mid-session rather than cold-start. + if (isConnectionRefused && !sessionHasProgress && attempt < maxRetries) { + // Reset to fresh-run mode. No session state carries forward because Claude Code + // never produced an assistant response for this session — the original prompt args + // (initialArgs/freshRetryArgs) are reused unchanged on the next attempt. + useContinueOnRetry = false; + log(`attempt ${attempt + 1}: connection refused before first assistant response — retrying as fresh run with backoff (attempt ${attempt + 2}/${maxRetries + 1})`); + continue; + } + // 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) { @@ -664,6 +717,8 @@ if (typeof module !== "undefined" && module.exports) { isNoDeferredMarkerError, isInvalidModelError, isInvalidJsonBodyError, + isConnectionRefusedError, + hasClaudeSessionProgress, isSignalTerminationExitCode, shouldRetryWithContinue, countPermissionDeniedIssues, diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index ca740c3f3b2..0d4be75337f 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -15,6 +15,8 @@ const { isNoDeferredMarkerError, isInvalidModelError, isInvalidJsonBodyError, + isConnectionRefusedError, + hasClaudeSessionProgress, isSignalTerminationExitCode, shouldRetryWithContinue, countPermissionDeniedIssues, @@ -305,6 +307,23 @@ describe("claude_harness.cjs", () => { }); }); + describe("connection-refused startup detection", () => { + it("detects common connection-refused messages", () => { + expect(isConnectionRefusedError("API Error: Connection refused")).toBe(true); + expect(isConnectionRefusedError("connect ECONNREFUSED 127.0.0.1:3128")).toBe(true); + }); + + it("distinguishes initialization output from assistant progress", () => { + expect(hasClaudeSessionProgress('{"type":"system","subtype":"init"}\nAPI Error: Connection refused')).toBe(false); + expect(hasClaudeSessionProgress('{"type":"assistant","message":{"content":[{"type":"text","text":"Working"}]}}')).toBe(true); + }); + + it("detects progress when connection refused appears after an assistant line", () => { + const output = '{"type":"assistant","message":{}}\nAPI Error: Connection refused'; + expect(hasClaudeSessionProgress(output)).toBe(true); + }); + }); + describe("isSignalTerminationExitCode", () => { it("returns true for SIGKILL/SIGTERM-style exit codes", () => { expect(isSignalTerminationExitCode(137)).toBe(true); @@ -539,6 +558,84 @@ process.exit(0); expect(result.stderr).toContain("failure_reason=cancelled_or_timed_out"); }, 30000); + it("retries a connection-refused failure before the first assistant response as a fresh run", () => { + 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.stderr.write('{"type":"system","subtype":"init"}\\nAPI Error: Connection refused\\n'); + process.exit(1); +} +process.stdout.write("startup 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 before first assistant response"); + }); + + it("continues a session that encounters a connection-refused failure after an assistant response", () => { + 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":"assistant","message":{"content":[{"type":"text","text":"Working"}]}}\\n'); + process.stderr.write("API Error: Connection refused\\n"); + process.exit(1); +} +process.stdout.write("resume 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, true]); + }); + + it("keeps resuming with --continue when a later continue attempt is refused during its own startup", () => { + 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":"assistant","message":{"content":[{"type":"text","text":"Working"}]}}\\n'); + process.stderr.write("API Error: Connection refused\\n"); + process.exit(1); +} +if (priorCalls === 1) { + process.stderr.write('{"type":"system","subtype":"init"}\\nAPI Error: Connection refused\\n'); + process.exit(1); +} +process.stdout.write("resume 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.length).toBe(3); + expect(calls.map(call => call.args.includes("--continue"))).toEqual([false, true, true]); + }); + it("retries one no-output startup failure as a fresh run by default", () => { const stubScript = ` const fs = require("fs");