fix(autofix): short-circuit 400 as terminal and classify only the last API error (#7247)

This commit is contained in:
qwen-code-ci-bot 2026-07-20 19:48:56 +00:00
parent 55563e37b4
commit 719991a3b8
2 changed files with 51 additions and 5 deletions

View file

@ -97,8 +97,13 @@ function classifyApiError(render) {
const status = Number(code);
if (status === 429 || (status >= 500 && status <= 599)) return 'transient';
if (status === 401 || status === 402 || status === 403) return 'auth';
// Any other code (400, 404, ...) is permanent UNLESS the message itself
// names an access/existence problem.
// 400 is always a malformed client request — it never self-heals by retry,
// regardless of what the message says. Route it terminal unconditionally
// so a 'does not exist' in the body (a tool name, a field name) cannot
// trigger the auth/access retry path.
if (status === 400) return '';
// Any other code (404, ...) is permanent UNLESS the message itself names
// an access/existence problem.
return AUTH_API_ERROR.test(render) ? 'auth' : '';
}
// Code-less render: fall back to the keyword arms.
@ -113,9 +118,13 @@ function classifyApiError(render) {
// failure), but detection is best-effort rather than guaranteed.
function recoverableApiError(output) {
const wrapped = output.match(/\[API Error:[^\]\n]*\]/g) || [];
for (const render of wrapped.reverse()) {
const kind = classifyApiError(render);
if (kind) return { error: render, kind };
if (wrapped.length > 0) {
// Classify only the LAST render — it represents the terminal state of the
// run. An earlier transient error followed by a permanent one must not
// retry: the permanent error reproduces identically on every attempt.
const last = wrapped[wrapped.length - 1];
const kind = classifyApiError(last);
if (kind) return { error: last, kind };
}
// Some quota errors are never wrapped in [API Error: ...] (e.g. Qwen OAuth
// quota returns early before formatting) - catch the known standalone form.

View file

@ -3404,6 +3404,11 @@ describe('qwen-autofix workflow', () => {
for (const render of [
'[API Error: 400 Invalid value for max_tokens: must be <= 512]',
'[API Error: 400 context length exceeded: 40000 > 32768]',
// A 400 whose message says 'does not exist' in a NON-access context
// (a tool name, a field name) must stay terminal — the AUTH_API_ERROR
// keyword 'does not exist' must not promote it to a retried auth error.
"[API Error: 400 Tool 'web_search' does not exist]",
"[API Error: 400 Field 'temperature' does not exist in schema]",
]) {
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
@ -3445,6 +3450,38 @@ describe('qwen-autofix workflow', () => {
}
});
it('classifies only the last API error — a terminal error after a transient one stays terminal', () => {
// If the output tail contains a transient error (429) followed by a
// permanent one (400), the last error represents the terminal state of
// the run. Retrying on the earlier transient error would hit the same
// permanent error every time.
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
const stub = writeQwenStub(dir, [
"process.stdout.write('[API Error: 429 Too Many Requests]\\n');",
"process.stdout.write('[API Error: 400 Bad request: malformed]\\n');",
'process.exit(1);',
]);
expect(runAddressReview(dir, stub).status).not.toBe(0);
expect(existsSync(join(dir, 'agent-api-error'))).toBe(false);
});
// The reverse order (permanent then transient) retries on the transient —
// the last error is the one that killed the run.
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
const stub = writeQwenStub(dir, [
"process.stdout.write('[API Error: 400 Bad request: malformed]\\n');",
"process.stdout.write('[API Error: 429 Too Many Requests]\\n');",
'process.exit(1);',
]);
expect(runAddressReview(dir, stub).status).not.toBe(0);
expect(existsSync(join(dir, 'agent-api-error'))).toBe(true);
expect(
readFileSync(join(dir, 'agent-api-error-kind'), 'utf8').trim(),
).toBe('transient');
});
});
it('keeps the API-error headline valid UTF-8 when the byte cap splits a CJK render', () => {
// `cut -c` counts bytes under GNU coreutils and the classifier deliberately
// matches Chinese renders, so the 200-byte cap can split a multi-byte