Skip to content

Commit 2eb41b5

Browse files
authored
Stop Codex harness retry loops on TPM exhaustion and unfinished-goal errors (#42420)
1 parent f838a6e commit 2eb41b5

5 files changed

Lines changed: 72 additions & 7 deletions

File tree

actions/setup/js/codex_harness.cjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const MAX_DELAY_MS = 60000;
6464
// the human-readable message Codex emits inside "Reconnecting..." / error lines:
6565
// "Rate limit reached for <model> in organization <org> on tokens per min (TPM): ..."
6666
const RATE_LIMIT_ERROR_PATTERN = /rate_limit_exceeded|429 Too Many Requests|RateLimitError|Rate limit reached for [^\s]+(?: in organization [^\s]+)? on tokens per min/i;
67+
const TOKEN_PER_MIN_RATE_LIMIT_PATTERN = /Rate limit reached for [^\s]+(?: in organization [^\s]+)? on tokens per min/i;
6768

6869
// Pattern to detect when Codex's internal stream-reconnect budget is fully spent.
6970
// Codex emits "Reconnecting... N/N (reason)" where both numbers are the same when
@@ -106,6 +107,17 @@ function isRateLimitError(output) {
106107
return RATE_LIMIT_ERROR_PATTERN.test(output);
107108
}
108109

110+
/**
111+
* Determines if the collected output indicates OpenAI token-per-minute exhaustion.
112+
* This limit is workload-dependent and immediate fresh-run retries can consume
113+
* additional budget without making progress.
114+
* @param {string} output - Collected stdout+stderr from the process
115+
* @returns {boolean}
116+
*/
117+
function isTokenPerMinuteRateLimitError(output) {
118+
return TOKEN_PER_MIN_RATE_LIMIT_PATTERN.test(output);
119+
}
120+
109121
/**
110122
* Determines if the collected output contains an authentication failed error.
111123
* @param {string} output - Collected stdout+stderr from the process
@@ -436,6 +448,7 @@ async function main() {
436448
}
437449

438450
const isRateLimit = isRateLimitError(result.output);
451+
const isTokenPerMinuteRateLimit = isTokenPerMinuteRateLimitError(result.output);
439452
const isAuthenticationFailed = isAuthenticationFailedError(result.output);
440453
const isMissingApiKey = isMissingApiKeyError(result.output);
441454
const isServer = isServerError(result.output);
@@ -446,6 +459,7 @@ async function main() {
446459
`attempt ${attempt + 1} failed:` +
447460
` exitCode=${result.exitCode}` +
448461
` isRateLimitError=${isRateLimit}` +
462+
` isTokenPerMinuteRateLimitError=${isTokenPerMinuteRateLimit}` +
449463
` isAuthenticationFailedError=${isAuthenticationFailed}` +
450464
` isMissingApiKeyError=${isMissingApiKey}` +
451465
` isServerError=${isServer}` +
@@ -506,6 +520,14 @@ async function main() {
506520
break;
507521
}
508522

523+
// Token-per-minute limits indicate exhausted budget for the current workload profile.
524+
// Fresh-run retries immediately repeat the same prompt workload and can quickly
525+
// drain available credits without making forward progress.
526+
if (isTokenPerMinuteRateLimit) {
527+
log(`attempt ${attempt + 1}: token-per-minute rate limit detected — not retrying (fresh runs can further drain token budget)`);
528+
break;
529+
}
530+
509531
// Codex's internal stream-reconnect retries are exhausted and the root cause is a
510532
// rate-limit error. Each reconnect attempt immediately failed with the same limit,
511533
// so a fresh harness run will encounter the same rate-limit at the same point in the
@@ -545,6 +567,7 @@ if (typeof module !== "undefined" && module.exports) {
545567
resolveCodexPromptFileArgs,
546568
injectJsonFlag,
547569
isRateLimitError,
570+
isTokenPerMinuteRateLimitError,
548571
isAuthenticationFailedError,
549572
isMissingApiKeyError,
550573
isServerError,

actions/setup/js/codex_harness.test.cjs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
resolveCodexPromptFileArgs,
1111
injectJsonFlag,
1212
isRateLimitError,
13+
isTokenPerMinuteRateLimitError,
1314
isAuthenticationFailedError,
1415
isMissingApiKeyError,
1516
isServerError,
@@ -90,6 +91,20 @@ describe("codex_harness.cjs", () => {
9091
expect(isRateLimitError("Error: rate_limit_exceeded")).toBe(true);
9192
});
9293

94+
describe("isTokenPerMinuteRateLimitError", () => {
95+
it("returns true for OpenAI TPM-limit wording", () => {
96+
expect(isTokenPerMinuteRateLimitError("Rate limit reached for gpt-4o-mini in organization org-xxx on tokens per min (TPM): Limit 200000, Used 166655, Requested 35398. Please try again in 615ms.")).toBe(true);
97+
});
98+
99+
it("returns false for generic rate-limit wording", () => {
100+
expect(isTokenPerMinuteRateLimitError("rate_limit_exceeded")).toBe(false);
101+
});
102+
103+
it("returns false for unrelated mention of tokens per min", () => {
104+
expect(isTokenPerMinuteRateLimitError("rate_limit_exceeded while printing docs about 'on tokens per min'")).toBe(false);
105+
});
106+
});
107+
93108
it("returns true for 429 Too Many Requests", () => {
94109
expect(isRateLimitError("429 Too Many Requests")).toBe(true);
95110
});
@@ -425,6 +440,8 @@ env_key = "OPENAI_API_KEY"
425440
const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output);
426441
if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive || nonRetryableGuard.maxRunsExceeded) return false;
427442
const isRateLimit = isRateLimitError(result.output);
443+
const isTokenPerMinuteRateLimit = isTokenPerMinuteRateLimitError(result.output);
444+
if (isTokenPerMinuteRateLimit) return false;
428445
if (isRateLimit && isReconnectExhaustedError(result.output)) return false;
429446
const isTransient = isRateLimit || isServerError(result.output);
430447
return attempt < MAX_RETRIES && (result.hasOutput || isTransient);
@@ -494,24 +511,33 @@ env_key = "OPENAI_API_KEY"
494511
expect(shouldRetry(result, 0)).toBe(false);
495512
});
496513

497-
it("retries on rate limit with format 'Rate limit reached for' without exhausted reconnects", () => {
514+
it("does not retry on token-per-minute rate limit wording", () => {
498515
const result = {
499516
exitCode: 1,
500517
hasOutput: false,
501518
output: '{"type":"error","message":"Rate limit reached for gpt-4o-mini in organization org-xxx on tokens per min (TPM): Limit 200000, Used 50000, Requested 35000. Please try again in 615ms."}',
502519
};
503-
expect(shouldRetry(result, 0)).toBe(true);
520+
expect(shouldRetry(result, 0)).toBe(false);
521+
});
522+
523+
it("does not retry on token-per-minute rate limit wording even with partial output", () => {
524+
const result = {
525+
exitCode: 1,
526+
hasOutput: true,
527+
output: '{"type":"error","message":"Rate limit reached for gpt-4o-mini in organization org-xxx on tokens per min (TPM): Limit 200000, Used 50000, Requested 35000. Please try again in 615ms."}',
528+
};
529+
expect(shouldRetry(result, 0)).toBe(false);
504530
});
505531

506-
it("does not retry when rate-limit reconnects are exhausted (N/N pattern)", () => {
532+
it("does not retry when rate-limit reconnects are exhausted (non-TPM rate limit)", () => {
507533
// Simulates the real log format: multiple Reconnecting... lines appear in
508534
// the output as codex retries the stream. The final "5/5" line is what
509535
// triggers the exhausted-reconnect detection; intermediate lines (1/5, 2/5)
510536
// confirm that the function ignores non-final attempts.
511537
const output =
512-
'{"type":"error","message":"Reconnecting... 1/5 (stream disconnected before completion: Rate limit reached for gpt-4o-mini on tokens per min (TPM): Limit 200000, Used 166655, Requested 35398. Please try again in 615ms.)"}\n' +
513-
'{"type":"error","message":"Reconnecting... 2/5 (stream disconnected before completion: Rate limit reached for gpt-4o-mini on tokens per min (TPM): Limit 200000, Used 166655, Requested 35398. Please try again in 615ms.)"}\n' +
514-
'{"type":"error","message":"Reconnecting... 5/5 (stream disconnected before completion: Rate limit reached for gpt-4o-mini on tokens per min (TPM): Limit 200000, Used 166655, Requested 35398. Please try again in 615ms.)"}';
538+
'{"type":"error","message":"Reconnecting... 1/5 (stream disconnected before completion: RateLimitError)"}\n' +
539+
'{"type":"error","message":"Reconnecting... 2/5 (stream disconnected before completion: RateLimitError)"}\n' +
540+
'{"type":"error","message":"Reconnecting... 5/5 (stream disconnected before completion: RateLimitError)"}';
515541
const result = { exitCode: 1, hasOutput: true, output };
516542
expect(shouldRetry(result, 0)).toBe(false);
517543
});

actions/setup/js/harness_retry_guard.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
const AI_CREDITS_EXCEEDED_PATTERNS = [/\bmax[\s_-]*ai[\s_-]*credits[\s_-]*exceeded\b/i, /\bai[\s_-]*credits[\s_-]*rate[\s_-]*limit[\s_-]*error\b/i, /ai[\s_-]*credits?.*(?:rate[\s-]*limit|limit exceeded|budget exceeded|exceeded)/i];
66

77
const AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS = [/\bawf\b.*\bapi[\s_-]*proxy\b.*\bblocking requests\b/i, /\bapi[\s_-]*proxy\b.*\bblocking requests\b/i, /\bapi[\s_-]*proxy\b.*\bblocked requests?\b/i, /\bDIFC_FILTERED\b/];
8-
const GOAL_ALREADY_ACTIVE_PATTERNS = [/\bthis thread already has a goal\b[\s\S]*?\buse update_goal\b/i];
8+
const GOAL_ALREADY_ACTIVE_PATTERNS = [/\bthis thread already has a goal\b[\s\S]*?\buse update_goal\b/i, /\bcannot create a new goal because this thread has an unfinished goal\b;\s*\bcomplete the existing goal first\b/i];
99

1010
// Patterns to detect Anthropic "max_runs_exceeded" (HTTP 403).
1111
// This occurs when the per-session LLM invocation quota is exhausted.

actions/setup/js/harness_retry_guard.test.cjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ describe("harness_retry_guard.cjs", () => {
9292
expect(result.goalAlreadyActive).toBe(true);
9393
});
9494

95+
it("detects goal already active for unfinished-goal wording", () => {
96+
const result = detectNonRetryableHarnessGuard("cannot create a new goal because this thread has an unfinished goal; complete the existing goal first");
97+
expect(result.goalAlreadyActive).toBe(true);
98+
});
99+
100+
it("detects goal already active for unfinished-goal wording (JSON-wrapped)", () => {
101+
const result = detectNonRetryableHarnessGuard('{"type":"error","message":"cannot create a new goal because this thread has an unfinished goal; complete the existing goal first"}');
102+
expect(result.goalAlreadyActive).toBe(true);
103+
});
104+
95105
it("detects max_runs_exceeded by JSON error type", () => {
96106
const result = detectNonRetryableHarnessGuard('{"error":{"type":"max_runs_exceeded","message":"Maximum LLM invocations exceeded (20 / 20).","invocation_count":20,"max_runs":20}}');
97107
expect(result.maxRunsExceeded).toBe(true);

docs/src/content/docs/reference/engines.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,12 @@ The value must be a bare filename — no directory separators, no `..`, and no s
296296
> [!NOTE]
297297
> `engine.harness` is currently only applied during Copilot engine execution. Setting it on other engines has no effect.
298298

299+
### Harness Retry Count
300+
301+
Built-in harness scripts (`copilot_harness.cjs`, `claude_harness.cjs`, `codex_harness.cjs`) currently use a fixed retry budget of **3 retries** after the initial run (4 total attempts).
302+
303+
To specify a different retry count, provide a custom harness script and implement your own retry policy there. At present, `engine.harness` customization is only applied for the Copilot engine.
304+
299305
**Validation rules:**
300306

301307
| Rule | Valid example | Invalid example |

0 commit comments

Comments
 (0)