From cc11def94ea27643db966193ed7cef60fd452e6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:29:45 +0000 Subject: [PATCH 1/3] Initial plan From 3114e94e139aa797e33c0ff8bfe4354da7a6487a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:40:52 +0000 Subject: [PATCH 2/3] Retry Claude connection refusals as fresh starts Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 40 ++++++++++++++- actions/setup/js/claude_harness.test.cjs | 63 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 03cd43f0a63..e1e574adcaa 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) && !isConnectionRefusedError(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 @@ -482,6 +503,8 @@ async function main() { const isNoDeferredMarker = isNoDeferredMarkerError(result.output); const isInvalidModel = isInvalidModelError(result.output); const isInvalidJsonBody = isInvalidJsonBodyError(result.output); + const isConnectionRefused = isConnectionRefusedError(result.output); + const hasSessionProgress = hasClaudeSessionProgress(result.output); const permissionDeniedCount = countPermissionDeniedIssues(result.output); const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output); log( @@ -494,6 +517,8 @@ async function main() { ` isNoDeferredMarkerError=${isNoDeferredMarker}` + ` isInvalidModelError=${isInvalidModel}` + ` isInvalidJsonBodyError=${isInvalidJsonBody}` + + ` isConnectionRefusedError=${isConnectionRefused}` + + ` hasSessionProgress=${hasSessionProgress}` + ` permissionDeniedCount=${permissionDeniedCount}` + ` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` + ` hasOutput=${result.hasOutput}` + @@ -598,6 +623,15 @@ 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. + if (isConnectionRefused && !hasSessionProgress && attempt < maxRetries) { + 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 +698,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..bd4f8bf8f9d 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,18 @@ 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); + }); + }); + describe("isSignalTerminationExitCode", () => { it("returns true for SIGKILL/SIGTERM-style exit codes", () => { expect(isSignalTerminationExitCode(137)).toBe(true); @@ -539,6 +553,55 @@ 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("retries one no-output startup failure as a fresh run by default", () => { const stubScript = ` const fs = require("fs"); From ad2e34ba2424cee87ca189f966fda380c6e019b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:30:10 +0000 Subject: [PATCH 3/3] Track session progress across continue attempts for connection-refused retries Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 27 ++++++++++++++++--- actions/setup/js/claude_harness.test.cjs | 34 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index e1e574adcaa..2873046a9b3 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -208,7 +208,7 @@ function isConnectionRefusedError(output) { * @returns {boolean} */ function hasClaudeSessionProgress(output) { - return output.split(/\r?\n/).some(line => /"type"\s*:\s*"assistant"/.test(line) && !isConnectionRefusedError(line)); + return output.split(/\r?\n/).some(line => /"type"\s*:\s*"assistant"/.test(line)); } /** @@ -446,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 @@ -468,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. @@ -504,7 +514,10 @@ async function main() { const isInvalidModel = isInvalidModelError(result.output); const isInvalidJsonBody = isInvalidJsonBodyError(result.output); const isConnectionRefused = isConnectionRefusedError(result.output); - const hasSessionProgress = hasClaudeSessionProgress(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( @@ -518,7 +531,7 @@ async function main() { ` isInvalidModelError=${isInvalidModel}` + ` isInvalidJsonBodyError=${isInvalidJsonBody}` + ` isConnectionRefusedError=${isConnectionRefused}` + - ` hasSessionProgress=${hasSessionProgress}` + + ` sessionHasProgress=${sessionHasProgress}` + ` permissionDeniedCount=${permissionDeniedCount}` + ` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` + ` hasOutput=${result.hasOutput}` + @@ -626,7 +639,13 @@ async function main() { // 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. - if (isConnectionRefused && !hasSessionProgress && attempt < maxRetries) { + // 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; diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index bd4f8bf8f9d..0d4be75337f 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -317,6 +317,11 @@ describe("claude_harness.cjs", () => { 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", () => { @@ -602,6 +607,35 @@ process.exit(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");