Repository navigation
fix(autofix): retry a model API error instead of stranding the PR #7247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
0e1ff5b
ec6e644
cbbbfad
4451171
f3e28ec
55563e3
719991a
5522ffb
0216f01
8956e0d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2763,11 +2763,13 @@ describe('qwen-autofix workflow', () => { | |||||||||||||||||||
| '::warning::Failed to post handoff comment on PR #${PR}', | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| expect(reviewAddressReportStep).toContain('human should take over'); | ||||||||||||||||||||
| // Token-breaking neutralization at ALL THREE model-output publish | ||||||||||||||||||||
| // sites, and it must be LINE-INDEPENDENT: a whole-comment strip misses | ||||||||||||||||||||
| // a marker whose --> sits on another line, while jq scan() matches | ||||||||||||||||||||
| // across newlines. Proven end-to-end on a split forged marker. | ||||||||||||||||||||
| expect(workflow.split("sed 's/<!--/<!\\\\-\\\\-/g'").length - 1).toBe(3); | ||||||||||||||||||||
| // Token-breaking neutralization at ALL FOUR agent-derived publish sites | ||||||||||||||||||||
| // (the three model-output bodies + the API_ERROR_DETAIL headline, whose | ||||||||||||||||||||
| // content is agent stdout that can echo external comment text), and it | ||||||||||||||||||||
| // must be LINE-INDEPENDENT: a whole-comment strip misses a marker whose | ||||||||||||||||||||
| // --> sits on another line, while jq scan() matches across newlines. | ||||||||||||||||||||
| // Proven end-to-end on a split forged marker. | ||||||||||||||||||||
| expect(workflow.split("sed 's/<!--/<!\\\\-\\\\-/g'").length - 1).toBe(4); | ||||||||||||||||||||
| const forged = | ||||||||||||||||||||
| '<!-- autofix-eval ts=2099-01-01T00:00:00Z\nx acted=true round=99 -->'; | ||||||||||||||||||||
| const sedCmd = workflow.match(/sed 's\/<!--\/[^']*\/g'/)?.[0]; | ||||||||||||||||||||
|
|
@@ -2888,6 +2890,7 @@ describe('qwen-autofix workflow', () => { | |||||||||||||||||||
| WATERMARK: '', | ||||||||||||||||||||
| DETAIL_FILE: '', | ||||||||||||||||||||
| NEWEST: '', | ||||||||||||||||||||
| API_ERROR_DETAIL: '', | ||||||||||||||||||||
| ...env, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| encoding: 'utf8', | ||||||||||||||||||||
|
|
@@ -2902,6 +2905,17 @@ describe('qwen-autofix workflow', () => { | |||||||||||||||||||
| DETAIL_FILE: '/tmp/failure.md', | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| ).toBe('2026-07-16T00:00:00Z|3'); | ||||||||||||||||||||
| // 1b. Agent DIED on a model [API Error] (access/quota/5xx) — it produced a | ||||||||||||||||||||
| // failure.md but evaluated NOTHING. Must be treated like a no-output | ||||||||||||||||||||
| // crash (sentinel ts, RETRY), not an evaluated handoff, so a model | ||||||||||||||||||||
| // access/quota blip does not strand the PR. This is the #7220-class fix. | ||||||||||||||||||||
| expect( | ||||||||||||||||||||
| runMark({ | ||||||||||||||||||||
| NEWEST: '2026-07-16T00:00:00Z', | ||||||||||||||||||||
| DETAIL_FILE: '/tmp/failure.md', | ||||||||||||||||||||
| API_ERROR_DETAIL: '[API Error: 403 Model access denied.]', | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| ).toBe(`${SENTINEL}|3`); | ||||||||||||||||||||
| // 2. Crash BEFORE any verdict (no output) though prepare ran: the watermark | ||||||||||||||||||||
| // must NOT advance (sentinel ts, excluded from EVAL_WM) so the next scan | ||||||||||||||||||||
| // RETRIES the same feedback; round still increments to bound the retries. | ||||||||||||||||||||
|
|
@@ -2929,6 +2943,7 @@ describe('qwen-autofix workflow', () => { | |||||||||||||||||||
| WATERMARK: '', | ||||||||||||||||||||
| DETAIL_FILE: '', | ||||||||||||||||||||
| NEWEST: '2026-07-16T00:00:00Z', | ||||||||||||||||||||
| API_ERROR_DETAIL: '', | ||||||||||||||||||||
| ...env, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| encoding: 'utf8', | ||||||||||||||||||||
|
|
@@ -2940,6 +2955,22 @@ describe('qwen-autofix workflow', () => { | |||||||||||||||||||
| expect(finalCrash).toContain('last automatic attempt'); | ||||||||||||||||||||
| expect(finalCrash).not.toContain('it will retry'); | ||||||||||||||||||||
| expect(finalCrash).not.toContain('Run log:'); | ||||||||||||||||||||
| // A model-API failure names the model issue and the operator fix, not a | ||||||||||||||||||||
| // generic crash/human-takeover — so the maintainer knows to check access. | ||||||||||||||||||||
| const midApi = runHeadline({ | ||||||||||||||||||||
| ROUND: '2', | ||||||||||||||||||||
| API_ERROR_DETAIL: '[API Error: 403 Model access denied.]', | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| expect(midApi).toContain('could not reach the model'); | ||||||||||||||||||||
| expect(midApi).toContain('403 Model access denied'); | ||||||||||||||||||||
| expect(midApi).toContain('it will retry on the next scan'); | ||||||||||||||||||||
| const finalApi = runHeadline({ | ||||||||||||||||||||
| ROUND: '4', | ||||||||||||||||||||
| API_ERROR_DETAIL: '[API Error: 403 Model access denied.]', | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| expect(finalApi).toContain('could not reach the model'); | ||||||||||||||||||||
| expect(finalApi).toContain('check the autofix model key/access'); | ||||||||||||||||||||
| expect(finalApi).not.toContain('it will retry'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Behaviorally replay the pending-staleness jq filter against sample checks so | ||||||||||||||||||||
| // a flipped comparison (which would age out live checks → double-processing) | ||||||||||||||||||||
|
|
@@ -3074,6 +3105,146 @@ describe('qwen-autofix workflow', () => { | |||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('flags a model [API Error] so the workflow retries instead of stranding the PR', () => { | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| // qwen renders a model access/quota/5xx error inline on stdout, then | ||||||||||||||||||||
| // exits non-zero — it never evaluated the feedback. | ||||||||||||||||||||
| const stub = writeQwenStub(dir, [ | ||||||||||||||||||||
| "process.stdout.write('[API Error: 403 Model access denied.]\\n');", | ||||||||||||||||||||
| 'process.exit(1);', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const result = runAddressReview(dir, stub); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| expect(result.status).not.toBe(0); | ||||||||||||||||||||
| // The dedicated marker the handoff step reads to route this to a retry | ||||||||||||||||||||
| // (sentinel ts, no watermark advance) rather than an evaluated handoff. | ||||||||||||||||||||
| expect(existsSync(join(dir, 'agent-api-error'))).toBe(true); | ||||||||||||||||||||
| expect(readFileSync(join(dir, 'agent-api-error'), 'utf8')).toContain( | ||||||||||||||||||||
| '403 Model access denied', | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| // The human-visible failure names the model error, not a bare status. | ||||||||||||||||||||
| expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain( | ||||||||||||||||||||
| '[API Error: 403 Model access denied.]', | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('does not flag a non-API subprocess failure for retry', () => { | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| const stub = writeQwenStub(dir, [ | ||||||||||||||||||||
| "process.stderr.write('some tool blew up\\n');", | ||||||||||||||||||||
| 'process.exit(1);', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const result = runAddressReview(dir, stub); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| expect(result.status).not.toBe(0); | ||||||||||||||||||||
| expect(existsSync(join(dir, 'agent-api-error'))).toBe(false); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('does not flag an API error that appears after a real verdict or a loop guard', () => { | ||||||||||||||||||||
| // Case C: the agent wrote its OWN failure.md (a real verdict) and an API | ||||||||||||||||||||
| // error also appears in the tail — that verdict must advance the watermark, | ||||||||||||||||||||
| // so NO retry marker. | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| const stub = writeWorkdirStub(dir, [ | ||||||||||||||||||||
| "writeFileSync(`${workdir}/failure.md`, 'my verdict\\n');", | ||||||||||||||||||||
| "process.stdout.write('[API Error: 429 quota exceeded]\\n');", | ||||||||||||||||||||
| 'process.exit(1);', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
| expect(runAddressReview(dir, stub).status).not.toBe(0); | ||||||||||||||||||||
| expect(existsSync(join(dir, 'agent-api-error'))).toBe(false); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| // Case B: a loop-guard trip is terminal even with an API error in the tail | ||||||||||||||||||||
| // (a loop run burns the full tool-call cap — retrying it 100× is the | ||||||||||||||||||||
| // opposite of what we want). | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| const stub = writeQwenStub(dir, [ | ||||||||||||||||||||
| "process.stderr.write('Loop detection halted the run\\n');", | ||||||||||||||||||||
| "process.stdout.write('[API Error: 503 upstream overloaded]\\n');", | ||||||||||||||||||||
| 'process.exit(1);', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
| expect(runAddressReview(dir, stub).status).not.toBe(0); | ||||||||||||||||||||
| expect(existsSync(join(dir, 'agent-api-error'))).toBe(false); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('flags recoverable API renders without a leading status code, and skips non-recoverable ones', () => { | ||||||||||||||||||||
| // The canonical rate-limit / bad-key renders carry no leading digit — these | ||||||||||||||||||||
| // must still retry (the 401/429 the loop actually hits in production). | ||||||||||||||||||||
| for (const render of [ | ||||||||||||||||||||
| '[API Error: Rate limit exceeded (Status: RESOURCE_EXHAUSTED)]', | ||||||||||||||||||||
| '[API Error: 401 Incorrect API key provided.]', | ||||||||||||||||||||
| ]) { | ||||||||||||||||||||
|
Comment on lines
+4272
to
+4275
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The four Chinese-language patterns in
Suggested change
— qwen3.8-max-preview via Qwen Code /review |
||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| const stub = writeQwenStub(dir, [ | ||||||||||||||||||||
| `process.stdout.write('${render}\\n');`, | ||||||||||||||||||||
| 'process.exit(1);', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
| expect(runAddressReview(dir, stub).status).not.toBe(0); | ||||||||||||||||||||
| expect(existsSync(join(dir, 'agent-api-error'))).toBe(true); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| // A 400 malformed request fails identically forever — stays terminal. | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| const stub = writeQwenStub(dir, [ | ||||||||||||||||||||
| "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); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('agrees on the agent-api-error marker name end to end (writer↔reader contract)', () => { | ||||||||||||||||||||
| // Extract the workflow READER *including* the ${WORKDIR}/agent-api-error | ||||||||||||||||||||
| // read (not just the MARK_TS block the other test drives via env), so a | ||||||||||||||||||||
| // rename on either side of the writer↔reader boundary breaks this test. | ||||||||||||||||||||
| const readerBlock = reviewAddressReportStep.match( | ||||||||||||||||||||
| /(DETAIL_FILE=''[\s\S]*?\n {12}fi)\n {12}\{/, | ||||||||||||||||||||
| )?.[1]; | ||||||||||||||||||||
| expect(readerBlock).toBeTruthy(); | ||||||||||||||||||||
| const SENTINEL = '9999-12-31T23:59:59Z'; | ||||||||||||||||||||
| const runReader = (dir) => | ||||||||||||||||||||
| execFileSync('bash', ['-c', `${readerBlock}\nprintf '%s' "$MARK_TS"`], { | ||||||||||||||||||||
| env: { | ||||||||||||||||||||
| ...process.env, | ||||||||||||||||||||
| WORKDIR: dir, | ||||||||||||||||||||
| MAX_ROUNDS: '5', | ||||||||||||||||||||
| ROUND: '2', | ||||||||||||||||||||
| WATERMARK: '', | ||||||||||||||||||||
| NEWEST: '2026-07-16T00:00:00Z', | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| encoding: 'utf8', | ||||||||||||||||||||
| }).trim(); | ||||||||||||||||||||
| // WRITER: the real run-agent.mjs drops agent-api-error on a model error. | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); | ||||||||||||||||||||
| const stub = writeQwenStub(dir, [ | ||||||||||||||||||||
| "process.stdout.write('[API Error: 429 quota exceeded]\\n');", | ||||||||||||||||||||
| 'process.exit(1);', | ||||||||||||||||||||
| ]); | ||||||||||||||||||||
| expect(runAddressReview(dir, stub).status).not.toBe(0); | ||||||||||||||||||||
| expect(existsSync(join(dir, 'agent-api-error'))).toBe(true); | ||||||||||||||||||||
| // The reader, pointed at that SAME workdir, must read the marker and | ||||||||||||||||||||
| // route to a retry (sentinel). A filename divergence advances instead. | ||||||||||||||||||||
| expect(runReader(dir)).toBe(SENTINEL); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| // No marker present → the reader advances the watermark (a real handoff). | ||||||||||||||||||||
| withRunnerDir((dir) => { | ||||||||||||||||||||
| writeFileSync(join(dir, 'failure.md'), 'verdict\n'); | ||||||||||||||||||||
| expect(runReader(dir)).toBe('2026-07-16T00:00:00Z'); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('bounds qwen subprocess runtime', () => { | ||||||||||||||||||||
| const runner = readFileSync(autofixRunnerScriptPath, 'utf8'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical] The bare
exceededalternative matches non-transient 400 errors (e.g. "context length exceeded", "maximum token limit exceeded"), classifying them as recoverable and triggering retries that fail identically every time — contradicting the comment on line 76 that "A 400 malformed request … fails identically forever and stays terminal (no match)." — Failure scenario: the model returns[API Error: 400 Bad request: context length exceeded]→exceededmatches →agent-api-errormarker written → workflow stamps sentinel ts → retries up to MAX_ROUNDS on a permanent error, wasting CI minutes and delaying human takeover. The existing test only covers[API Error: 400 Bad request: malformed], which lacks the word "exceeded".— qwen3.8-max-preview via Qwen Code /review