Skip to content

Commit 506da6a

Browse files
Merge branch 'main' into copilot/concurrency-fix-assign-to-agent
2 parents f427764 + cccc09a commit 506da6a

2 files changed

Lines changed: 154 additions & 2 deletions

File tree

actions/setup/js/claude_harness.cjs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
* observed immediately after a `permission_denied` tool-result on a compound Bash command.
1919
* It is retried as a fresh run (not `--continue`, which is permanently disabled for the rest
2020
* of the driver invocation) since resuming would resend the same corrupted session state.
21-
* - If the process produced no output (failed to start / auth error before any work), the
22-
* driver does not retry because there is nothing to resume.
21+
* - Connection-refused failures before the first assistant response are retried as fresh
22+
* runs because there is no session state to resume.
23+
* - Other failures that produce no output use a separate bounded startup retry budget.
2324
* - On a `--continue` retry the initial prompt is omitted: Claude Code resumes the session
2425
* from its on-disk state rather than re-processing the original instructions.
2526
* - 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
8182
// run rather than --continue, since resuming would resend the same corrupted
8283
// session state and reproduce the identical error.
8384
const INVALID_JSON_BODY_ERROR_PATTERN = /request body is not valid JSON/i;
85+
const CONNECTION_REFUSED_ERROR_PATTERN = /connection refused|ECONNREFUSED/i;
8486

8587
// Pattern to detect a clean max-turns exit from Claude Code.
8688
// Claude Code emits a JSON result object with "subtype":"error_max_turns" when the
@@ -190,6 +192,25 @@ function isInvalidJsonBodyError(output) {
190192
return INVALID_JSON_BODY_ERROR_PATTERN.test(output);
191193
}
192194

195+
/**
196+
* Determines if the collected output contains a refused network connection.
197+
* @param {string} output - Collected stdout+stderr from the process
198+
* @returns {boolean}
199+
*/
200+
function isConnectionRefusedError(output) {
201+
return CONNECTION_REFUSED_ERROR_PATTERN.test(output);
202+
}
203+
204+
/**
205+
* Determines whether Claude produced an assistant response before failing.
206+
* System initialization and transport-error events do not represent resumable work.
207+
* @param {string} output - Collected stdout+stderr from the process
208+
* @returns {boolean}
209+
*/
210+
function hasClaudeSessionProgress(output) {
211+
return output.split(/\r?\n/).some(line => /"type"\s*:\s*"assistant"/.test(line));
212+
}
213+
193214
/**
194215
* Determines if the collected output contains a "no deferred tool marker" error.
195216
* This occurs when Claude Code is invoked with --continue but the session was never
@@ -425,6 +446,12 @@ async function main() {
425446
let useContinueOnRetry = false;
426447
let continueDisabledPermanently = false;
427448
let startupRetriesUsed = 0;
449+
// Tracks whether the *active session* (the run currently being resumed via --continue)
450+
// has ever produced an assistant response. This must persist across attempts — a later
451+
// --continue attempt can fail during its own startup (e.g. connection refused before it
452+
// emits anything) even though earlier attempts in the same session already made progress.
453+
// Reset only when a genuinely fresh run begins (see below), never on a --continue attempt.
454+
let sessionHasProgress = false;
428455
const driverStartTime = Date.now();
429456
// Soft-timeout guard: polled at the top of the retry loop and after each backoff sleep.
430457
// It does not preempt a running attempt — if a single invocation runs past the soft
@@ -447,6 +474,10 @@ async function main() {
447474
currentArgs = [...continueBaseArgs, "--continue"];
448475
} else {
449476
currentArgs = attempt === 0 ? initialArgs : freshRetryArgs;
477+
// This attempt starts a brand-new session (either attempt 0, or a fresh
478+
// retry that discards prior on-disk state) — no assistant progress can carry
479+
// forward from any earlier attempt, so reset the tracker.
480+
sessionHasProgress = false;
450481
}
451482

452483
// Use redacted args for logging when the run carries the prompt text.
@@ -482,6 +513,11 @@ async function main() {
482513
const isNoDeferredMarker = isNoDeferredMarkerError(result.output);
483514
const isInvalidModel = isInvalidModelError(result.output);
484515
const isInvalidJsonBody = isInvalidJsonBodyError(result.output);
516+
const isConnectionRefused = isConnectionRefusedError(result.output);
517+
// Accumulate across attempts of the same session: once an assistant response has been
518+
// observed, it stays true for the remainder of this session's --continue attempts, even
519+
// if a later attempt's own output contains nothing but startup/transport errors.
520+
sessionHasProgress = sessionHasProgress || hasClaudeSessionProgress(result.output);
485521
const permissionDeniedCount = countPermissionDeniedIssues(result.output);
486522
const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues(result.output);
487523
log(
@@ -494,6 +530,8 @@ async function main() {
494530
` isNoDeferredMarkerError=${isNoDeferredMarker}` +
495531
` isInvalidModelError=${isInvalidModel}` +
496532
` isInvalidJsonBodyError=${isInvalidJsonBody}` +
533+
` isConnectionRefusedError=${isConnectionRefused}` +
534+
` sessionHasProgress=${sessionHasProgress}` +
497535
` permissionDeniedCount=${permissionDeniedCount}` +
498536
` hasNumerousPermissionDenied=${hasNumerousPermissionDenied}` +
499537
` hasOutput=${result.hasOutput}` +
@@ -598,6 +636,21 @@ async function main() {
598636
break;
599637
}
600638

639+
// A refused connection before Claude produces an assistant response means the API
640+
// proxy path was unavailable during startup. There is no session state to resume, so
641+
// retry the original prompt as a fresh run with the normal exponential backoff.
642+
// sessionHasProgress reflects the whole session, not just this attempt's output, so a
643+
// later --continue attempt that fails during its own startup (no assistant line of its
644+
// own) is still correctly treated as mid-session rather than cold-start.
645+
if (isConnectionRefused && !sessionHasProgress && attempt < maxRetries) {
646+
// Reset to fresh-run mode. No session state carries forward because Claude Code
647+
// never produced an assistant response for this session — the original prompt args
648+
// (initialArgs/freshRetryArgs) are reused unchanged on the next attempt.
649+
useContinueOnRetry = false;
650+
log(`attempt ${attempt + 1}: connection refused before first assistant response — retrying as fresh run with backoff (attempt ${attempt + 2}/${maxRetries + 1})`);
651+
continue;
652+
}
653+
601654
// Retry when the session was partially executed (has output).
602655
// Use --continue so Claude Code can resume from its saved session state.
603656
if (attempt < maxRetries && result.hasOutput) {
@@ -664,6 +717,8 @@ if (typeof module !== "undefined" && module.exports) {
664717
isNoDeferredMarkerError,
665718
isInvalidModelError,
666719
isInvalidJsonBodyError,
720+
isConnectionRefusedError,
721+
hasClaudeSessionProgress,
667722
isSignalTerminationExitCode,
668723
shouldRetryWithContinue,
669724
countPermissionDeniedIssues,

actions/setup/js/claude_harness.test.cjs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const {
1515
isNoDeferredMarkerError,
1616
isInvalidModelError,
1717
isInvalidJsonBodyError,
18+
isConnectionRefusedError,
19+
hasClaudeSessionProgress,
1820
isSignalTerminationExitCode,
1921
shouldRetryWithContinue,
2022
countPermissionDeniedIssues,
@@ -305,6 +307,23 @@ describe("claude_harness.cjs", () => {
305307
});
306308
});
307309

310+
describe("connection-refused startup detection", () => {
311+
it("detects common connection-refused messages", () => {
312+
expect(isConnectionRefusedError("API Error: Connection refused")).toBe(true);
313+
expect(isConnectionRefusedError("connect ECONNREFUSED 127.0.0.1:3128")).toBe(true);
314+
});
315+
316+
it("distinguishes initialization output from assistant progress", () => {
317+
expect(hasClaudeSessionProgress('{"type":"system","subtype":"init"}\nAPI Error: Connection refused')).toBe(false);
318+
expect(hasClaudeSessionProgress('{"type":"assistant","message":{"content":[{"type":"text","text":"Working"}]}}')).toBe(true);
319+
});
320+
321+
it("detects progress when connection refused appears after an assistant line", () => {
322+
const output = '{"type":"assistant","message":{}}\nAPI Error: Connection refused';
323+
expect(hasClaudeSessionProgress(output)).toBe(true);
324+
});
325+
});
326+
308327
describe("isSignalTerminationExitCode", () => {
309328
it("returns true for SIGKILL/SIGTERM-style exit codes", () => {
310329
expect(isSignalTerminationExitCode(137)).toBe(true);
@@ -539,6 +558,84 @@ process.exit(0);
539558
expect(result.stderr).toContain("failure_reason=cancelled_or_timed_out");
540559
}, 30000);
541560

561+
it("retries a connection-refused failure before the first assistant response as a fresh run", () => {
562+
const stubScript = `
563+
const fs = require("fs");
564+
const callsPath = process.env.CLAUDE_HARNESS_STUB_CALLS;
565+
const args = process.argv.slice(2);
566+
const priorCalls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").trim().split("\\n").filter(Boolean).length : 0;
567+
fs.appendFileSync(callsPath, JSON.stringify({ args }) + "\\n", "utf8");
568+
if (priorCalls === 0) {
569+
process.stderr.write('{"type":"system","subtype":"init"}\\nAPI Error: Connection refused\\n');
570+
process.exit(1);
571+
}
572+
process.stdout.write("startup retry succeeded\\n");
573+
process.exit(0);
574+
`;
575+
const { result, calls } = runHarnessWithStub({
576+
stubScript,
577+
extraEnv: { GH_AW_HARNESS_INITIAL_DELAY_MS: "1" },
578+
});
579+
580+
expect(result.status, result.stderr).toBe(0);
581+
expect(calls.map(call => call.args.includes("--continue"))).toEqual([false, false]);
582+
expect(calls[1].args).toContain("fix the bug");
583+
expect(result.stderr).toContain("connection refused before first assistant response");
584+
});
585+
586+
it("continues a session that encounters a connection-refused failure after an assistant response", () => {
587+
const stubScript = `
588+
const fs = require("fs");
589+
const callsPath = process.env.CLAUDE_HARNESS_STUB_CALLS;
590+
const args = process.argv.slice(2);
591+
const priorCalls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").trim().split("\\n").filter(Boolean).length : 0;
592+
fs.appendFileSync(callsPath, JSON.stringify({ args }) + "\\n", "utf8");
593+
if (priorCalls === 0) {
594+
process.stdout.write('{"type":"assistant","message":{"content":[{"type":"text","text":"Working"}]}}\\n');
595+
process.stderr.write("API Error: Connection refused\\n");
596+
process.exit(1);
597+
}
598+
process.stdout.write("resume succeeded\\n");
599+
process.exit(0);
600+
`;
601+
const { result, calls } = runHarnessWithStub({
602+
stubScript,
603+
extraEnv: { GH_AW_HARNESS_INITIAL_DELAY_MS: "1" },
604+
});
605+
606+
expect(result.status, result.stderr).toBe(0);
607+
expect(calls.map(call => call.args.includes("--continue"))).toEqual([false, true]);
608+
});
609+
610+
it("keeps resuming with --continue when a later continue attempt is refused during its own startup", () => {
611+
const stubScript = `
612+
const fs = require("fs");
613+
const callsPath = process.env.CLAUDE_HARNESS_STUB_CALLS;
614+
const args = process.argv.slice(2);
615+
const priorCalls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, "utf8").trim().split("\\n").filter(Boolean).length : 0;
616+
fs.appendFileSync(callsPath, JSON.stringify({ args }) + "\\n", "utf8");
617+
if (priorCalls === 0) {
618+
process.stdout.write('{"type":"assistant","message":{"content":[{"type":"text","text":"Working"}]}}\\n');
619+
process.stderr.write("API Error: Connection refused\\n");
620+
process.exit(1);
621+
}
622+
if (priorCalls === 1) {
623+
process.stderr.write('{"type":"system","subtype":"init"}\\nAPI Error: Connection refused\\n');
624+
process.exit(1);
625+
}
626+
process.stdout.write("resume succeeded\\n");
627+
process.exit(0);
628+
`;
629+
const { result, calls } = runHarnessWithStub({
630+
stubScript,
631+
extraEnv: { GH_AW_HARNESS_INITIAL_DELAY_MS: "1" },
632+
});
633+
634+
expect(result.status, result.stderr).toBe(0);
635+
expect(calls.length).toBe(3);
636+
expect(calls.map(call => call.args.includes("--continue"))).toEqual([false, true, true]);
637+
});
638+
542639
it("retries one no-output startup failure as a fresh run by default", () => {
543640
const stubScript = `
544641
const fs = require("fs");

0 commit comments

Comments
 (0)