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
59 changes: 57 additions & 2 deletions actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -494,6 +530,8 @@ async function main() {
` isNoDeferredMarkerError=${isNoDeferredMarker}` +
` isInvalidModelError=${isInvalidModel}` +
` isInvalidJsonBodyError=${isInvalidJsonBody}` +
` isConnectionRefusedError=${isConnectionRefused}` +
` sessionHasProgress=${sessionHasProgress}` +
` permissionDeniedCount=${permissionDeniedCount}` +
` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` +
` hasOutput=${result.hasOutput}` +
Expand Down Expand Up @@ -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})`);
Comment thread
github-actions[bot] marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -664,6 +717,8 @@ if (typeof module !== "undefined" && module.exports) {
isNoDeferredMarkerError,
isInvalidModelError,
isInvalidJsonBodyError,
isConnectionRefusedError,
hasClaudeSessionProgress,
isSignalTerminationExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
Expand Down
97 changes: 97 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,8 @@ const {
isNoDeferredMarkerError,
isInvalidModelError,
isInvalidJsonBodyError,
isConnectionRefusedError,
hasClaudeSessionProgress,
isSignalTerminationExitCode,
shouldRetryWithContinue,
countPermissionDeniedIssues,
Expand Down Expand Up @@ -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);
Comment thread
github-actions[bot] marked this conversation as resolved.
});

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);
Expand Down Expand Up @@ -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");
Expand Down
Loading