Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
56 changes: 38 additions & 18 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2485,7 +2485,7 @@ jobs:
if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then
git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true
fi
for f in feedback.md address-summary.md no-action.md failure.md handoff.md pr.diff; do
for f in feedback.md address-summary.md no-action.md failure.md handoff.md agent-api-error pr.diff; do
if [[ -f "${WORKDIR}/${f}" ]]; then
echo "=============== ${f} ==============="
cat "${WORKDIR}/${f}"
Expand Down Expand Up @@ -2697,6 +2697,18 @@ jobs:
for f in failure.md handoff.md address-summary.md no-action.md; do
if [[ -s "${WORKDIR}/${f}" ]]; then DETAIL_FILE="${WORKDIR}/${f}"; break; fi
done
# run-agent.mjs drops this marker when qwen died on a model-side
# [API Error: 4xx/5xx] (access denied, quota, a 5xx). The agent never
# evaluated the feedback, so this is treated as a retry (sentinel ts,
# no watermark advance) below — a model access/quota blip must not
# strand the PR the way a real evaluated handoff would.
API_ERROR_DETAIL=''
if [[ -s "${WORKDIR}/agent-api-error" ]]; then
# First line only, comment-opener escaped (agent stdout can echo
# external PR-comment text and the marker regex spans '<!-- ... -->'
# happily), and capped so a long span can't bloat the headline.
API_ERROR_DETAIL="$(head -1 "${WORKDIR}/agent-api-error" | sed 's/<!--/<!\\-\\-/g' | cut -c1-200)"
fi
# If feedback was actually read (prepare ran), stamp its newest ts so
# the watermark advances and the same feedback is not re-selected next
# scan. If the crash happened before prepare, NEWEST is empty and the
Expand All @@ -2712,28 +2724,36 @@ jobs:
MARK_TS="${NEWEST:-${WATERMARK:-9999-12-31T23:59:59Z}}"
if [[ -n "${NEWEST:-}" ]]; then
MARK_ROUND="$(( ROUND + 1 ))"
if [[ -z "${DETAIL_FILE}" ]]; then
# Prepare ran (NEWEST is set) but the agent produced NO output
# at all — it crashed before writing any verdict (e.g. a staged
# runner that fails to boot). It evaluated NOTHING, so the
# watermark must NOT advance past this feedback: an advance makes
# the next scan see "nothing new" and never retry, stranding the
# PR on a transient crash (a deploy, an infra blip, a base-image
# bug fixed minutes later). Stamp the sentinel ts (excluded from
# EVAL_WM) so the feedback stays live and the next scan retries;
# the incremented round still bounds retries to MAX_ROUNDS before
# a terminal handoff, so a PERSISTENT crash cannot loop forever.
if [[ -z "${DETAIL_FILE}" || -n "${API_ERROR_DETAIL}" ]]; then
# Either the agent produced NO output (crashed before any verdict
# — a staged runner that fails to boot) OR it died on a model
# [API Error] (access/quota/5xx). In BOTH cases it evaluated
# NOTHING, so the watermark must NOT advance past this feedback:
# an advance makes the next scan see "nothing new" and never
# retry, stranding the PR on a transient failure (an infra blip,
# a quota reset minutes away, a model-access grant). Stamp the
# sentinel ts (excluded from EVAL_WM) so the feedback stays live
# and the next scan retries; the incremented round still bounds
# retries to MAX_ROUNDS before a terminal handoff.
MARK_TS='9999-12-31T23:59:59Z'
# Cause-aware wording: a model error names the model issue and
# the operator fix; a bare crash points at a human. No Run log
# here — the report block below appends it (avoid a duplicate).
if [[ -n "${API_ERROR_DETAIL}" ]]; then
CAUSE="could not reach the model — ${API_ERROR_DETAIL}"
LAST_FIX="a maintainer should check the autofix model key/access, then re-arm"
else
CAUSE="crashed before it could evaluate the feedback"
LAST_FIX="a human should take over this PR"
fi
# Only promise a retry when one will actually happen: at
# MARK_ROUND == MAX_ROUNDS the next scan's round-cap gate skips
# the PR, and the cap-reached notice is takeover-only — so the
# final attempt must say so itself, or the maintainer waits for
# a retry that never comes. No Run log here: the report block
# below appends it to every handoff (avoid a duplicate URL).
# the PR, and the cap-reached notice is takeover-only, so the
# final attempt must say so itself.
if [[ "${MARK_ROUND}" -lt "${MAX_ROUNDS}" ]]; then
HEADLINE="🤖 AutoFix crashed before it could evaluate the feedback (attempt ${MARK_ROUND}/${MAX_ROUNDS}) — it will retry on the next scan."
HEADLINE="🤖 AutoFix ${CAUSE} (attempt ${MARK_ROUND}/${MAX_ROUNDS}) — it will retry on the next scan."
else
HEADLINE="🤖 AutoFix crashed before it could evaluate the feedback (attempt ${MARK_ROUND}/${MAX_ROUNDS}) — this was the last automatic attempt; a human should take over this PR."
HEADLINE="🤖 AutoFix ${CAUSE} (attempt ${MARK_ROUND}/${MAX_ROUNDS}) — this was the last automatic attempt; ${LAST_FIX}."
fi
else
HEADLINE="🤖 Could not address the latest feedback automatically (round ${MARK_ROUND}/${MAX_ROUNDS}). A human should take over this PR."
Expand Down
37 changes: 36 additions & 1 deletion .qwen/skills/autofix/scripts/run-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ function writeFailure(workdir, message) {
);
}

// Extract a RECOVERABLE model error from the captured output, or '' if none.
// Recoverable = transient (5xx, rate limit, overload) or operator-fixable
// config (401 bad key, 402 billing, 403 access, quota) — these self-heal once
// the quota resets or the key/access is fixed, so the workflow retries them.
// A 400 malformed request or a plain 404 fails identically forever and stays
// terminal (no match). The match is single-line ([^\]\n]) so a multi-line
// render cannot smuggle a newline into the marker or the PR-comment headline.
const RECOVERABLE_API_ERROR =
/\b(?:401|402|403|429|5\d\d)\b|rate.?limit|quota|exceeded|RESOURCE_EXHAUSTED|overloaded|temporarily|too many requests|api key|\u901f\u7387\u9650\u5236|\u914d\u989d|\u670d\u52a1(?:\u7e41\u5fd9|\u4e0d\u53ef\u7528)/i;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The bare exceeded alternative 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] → exceeded matches → agent-api-error marker 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".

Suggested change
const RECOVERABLE_API_ERROR =
/\b(?:401|402|403|429|5\d\d)\b|rate.?limit|quota|exceeded|RESOURCE_EXHAUSTED|overloaded|temporarily|too many requests|api key|\u901f\u7387\u9650\u5236|\u914d\u989d|\u670d\u52a1(?:\u7e41\u5fd9|\u4e0d\u53ef\u7528)/i;
const RECOVERABLE_API_ERROR =
/\b(?:401|402|403|429|5\d\d)\b|rate.?limit|quota|RESOURCE_EXHAUSTED|overloaded|temporarily|too many requests|api key|\u901f\u7387\u9650\u5236|\u914d\u989d|\u670d\u52a1(?:\u7e41\u5fd9|\u4e0d\u53ef\u7528)/i;

— qwen3.8-max-preview via Qwen Code /review

function recoverableApiError(output) {
const wrapped = output.match(/\[API Error:[^\]\n]*\]/g) || [];
const hit = wrapped.reverse().find((e) => RECOVERABLE_API_ERROR.test(e));
if (hit) return hit;
// Some quota errors are never wrapped in [API Error: …] (e.g. Qwen OAuth
// quota returns early before formatting) — catch the known standalone form.
const oauth = output.match(/Qwen OAuth quota exceeded[^\n]*/i);
return oauth ? `[API Error: ${oauth[0]}]` : '';
Comment thread
wenshao marked this conversation as resolved.
Outdated
}

function writeHandoff(workdir, message) {
mkdirSync(workdir, { recursive: true });
writeFileSync(file(workdir, 'handoff.md'), `${message}\n`);
Expand Down Expand Up @@ -115,6 +134,9 @@ function runQwen(options, prompt) {
...result,
timedOut,
loopDetected: loopDetected || isLoopGuardOutput(outputTail),
// A RECOVERABLE model error means qwen never evaluated the feedback —
// the workflow retries it rather than advancing the watermark.
apiError: recoverableApiError(outputTail),
};
if (log.destroyed) {
resolve(payload);
Expand Down Expand Up @@ -227,8 +249,21 @@ if (result.error || result.signal || result.status !== 0) {
} else {
writeFailure(
options.workdir,
`Qwen failed during ${options.mode}: ${detail}.`,
`Qwen failed during ${options.mode}: ${detail}.${
result.apiError ? ` ${result.apiError}` : ''
}`,
);
// Only a BARE, un-evaluated API failure is retryable. A loop guard, a
// timeout, or an agent-written failure.md is a real verdict — leave
// those terminal even if an API-error string appears in the output tail.
// Signals the workflow to retry (sentinel ts) instead of advancing the
// watermark and stranding the PR.
if (result.apiError && !result.timedOut) {
writeFileSync(
file(options.workdir, 'agent-api-error'),
`${result.apiError}\n`,
);
}
Comment thread
wenshao marked this conversation as resolved.
}
} else {
writeHandoff(
Expand Down
181 changes: 176 additions & 5 deletions scripts/tests/qwen-autofix-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -2888,6 +2890,7 @@ describe('qwen-autofix workflow', () => {
WATERMARK: '',
DETAIL_FILE: '',
NEWEST: '',
API_ERROR_DETAIL: '',
...env,
},
encoding: 'utf8',
Expand All @@ -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.
Expand Down Expand Up @@ -2929,6 +2943,7 @@ describe('qwen-autofix workflow', () => {
WATERMARK: '',
DETAIL_FILE: '',
NEWEST: '2026-07-16T00:00:00Z',
API_ERROR_DETAIL: '',
...env,
},
encoding: 'utf8',
Expand All @@ -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)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The four Chinese-language patterns in RECOVERABLE_API_ERROR (速度限制, 配额, 服务繁忙, 服务不可用) have zero test coverage. A typo in any Unicode escape would silently disable retry for Chinese-locale model errors — the exact bug this PR fixes, but for Chinese error renders. — Concrete cost: a contributor reformats the regex and corrupts \u901f → \u90lf; [API Error: 服务繁忙,请稍后重试] no longer matches → no marker → watermark advances → PR stranded.

Suggested change
for (const render of [
'[API Error: Rate limit exceeded (Status: RESOURCE_EXHAUSTED)]',
'[API Error: 401 Incorrect API key provided.]',
]) {
for (const render of [
'[API Error: Rate limit exceeded (Status: RESOURCE_EXHAUSTED)]',
'[API Error: 401 Incorrect API key provided.]',
'[API Error: 服务繁忙,请稍后重试]',
]) {

— 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');

Expand Down
Loading