diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index dc209d65ce8..fc207dde070 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -2044,16 +2044,6 @@ jobs: | [ scan("") ] | .[] | {sha: .[0], at: ($c.created_at // "")} ] | sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")" - # The head a gate-rejection handoff last parked this PR on (mirror of - # RED_HEAD). While it equals LIVE_HEAD the PR is parked on the CURRENT - # head: a later push writes a newer eval comment WITHOUT this marker, - # so the latest handoff head stops matching and the parking clears. - # Used only to auto-recover a stale-base parking (see the idle path). - HANDOFF_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" ' - [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") - | [ scan("") ] | .[] - | {sha: .[0], at: ($c.created_at // "")} ] - | sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")" # A maintainer '@qwen-code /retry' posts that marker to say # "evaluate this feedback again": markers written BEFORE it stop # holding the watermark down. This is the sanctioned exception to @@ -2310,27 +2300,6 @@ jobs: if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then HAS_CONFLICT='true'; fi if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${N_RED_NOW}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then - # Nothing new — but is this a stale-base PARKING? A gate-rejection - # handoff left an autofix-handoff marker on the head it parked on. - # While that still matches LIVE_HEAD (no push since) and the head is - # now BEHIND main, the build that failed may have failed on a - # since-changed dependency (#7471), not the fix. Merge main in and - # re-arm so the loop re-reads the feedback against fresh main. The - # forward case is handled in-round by the report step; this recovers - # a PR that was ALREADY parked when the base moved under it. Guarded - # by head-match (a push clears it) and behind-main (self-limiting: - # the update makes it current, so it cannot re-fire), and every API - # call is fail-safe — any failure just falls through to idle. - if [[ -n "${LIVE_HEAD}" && "${HANDOFF_HEAD}" == "${LIVE_HEAD}" ]]; then - UNPARK_CMP="$(gh api "repos/${REPO}/compare/${DEFAULT_BRANCH:-main}...${LIVE_HEAD}" --jq '.status' 2> /dev/null || echo '')" - if [[ "${UNPARK_CMP}" == 'behind' || "${UNPARK_CMP}" == 'diverged' ]] \ - && gh api -X PUT "repos/${REPO}/pulls/${PR}/update-branch" -f expected_head_sha="${LIVE_HEAD}" > /dev/null 2>&1; then - gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '♻️ AutoFix auto-recovered a stale base: this PR was parked after a fix failed to build, but its head was behind `%s`, so current main was merged in via update-branch and the next scan will re-read the feedback against it. A stale base can fail the build without being the fix at fault; if it still fails once current, it hands off to a human again.\n\n
\n中文说明\n\n♻️ AutoFix 自动恢复了陈旧 base:本 PR 因修复构建失败而被搁置,但其 head 落后于 `%s`,故已将当前 main 合入,下次扫描会对它重读反馈。陈旧 base 可能使构建失败而并非修复之过;若更新后仍失败,将再次交人。\n\n
\n\n' "${DEFAULT_BRANCH:-main}" "${DEFAULT_BRANCH:-main}")" || echo "::warning::Failed to post stale-base recovery comment on PR #${PR}" - echo "♻️ #${PR}: parked on a stale base (behind ${DEFAULT_BRANCH:-main}) — merged main + re-armed" - fleet_row "${PR}" 'unparked' "stale-base parking recovered — merged ${DEFAULT_BRANCH:-main} + re-armed" - continue - fi - fi echo "✅ #${PR}: nothing new since ${EFF_WM} (conflict=${HAS_CONFLICT})" fleet_row "${PR}" 'idle' "nothing new since ${EFF_WM} (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})" continue @@ -3633,12 +3602,6 @@ jobs: HEADLINE="🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind \`${DEFAULT_BRANCH:-main}\`, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human." else HEADLINE="🤖 Could not address the latest feedback automatically (round ${MARK_ROUND}/${MAX_ROUNDS}). A human should take over this PR." - # The gate rejected a real fix and the PR is up to date now, so - # this is a genuine parking. Flag it head-scoped so a LATER - # scan can auto-recover it if the base goes stale under it - # (behind main → the build may have failed on a since-changed - # dependency, not the fix). See the autofix-handoff marker. - PARKED_STALE_CANDIDATE=true fi fi elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then @@ -3799,18 +3762,6 @@ jobs: if [[ "${MARK_TS}" != '9999-12-31T23:59:59Z' ]]; then echo "" fi - # A gate-REJECTION handoff (the agent produced a fix that failed - # verification, so the PR is now parked with the watermark - # advanced) is a stale-base recovery candidate: if this head later - # falls behind main, the build may have failed on something main - # has since changed (#7471's update-notifier), not the fix. The - # scan reads this head-scoped marker and, only while it still - # matches LIVE_HEAD (a later push clears it), auto-updates the base - # and re-arms. Head-scoped like autofix-redcheck, and only on this - # branch — a crash/timeout handoff produced no fix to re-verify. - if [[ "${PARKED_STALE_CANDIDATE:-false}" == 'true' ]]; then - echo "" - fi } > "${WORKDIR}/report.md" gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}" fi diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 1948bf8cb58..9365bf0c245 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -1032,202 +1032,6 @@ describe('qwen-autofix workflow', () => { // budget is tight for this many cases, so give it a comfortable margin. }, 20000); - it('auto-recovers a PR parked on a stale base: handoff head matches + behind main → update-branch + re-arm', () => { - const block = reviewScanJob.match( - /( {12}if \[\[ "\$\{N_REVIEWS\}" -eq 0[\s\S]*?\n {12}fi)\n\n {12}echo "🔎/, - )?.[1]; - expect(block).toBeTruthy(); - const script = block.replace(/^ {12}/gm, ''); - - const run = ({ - handoffHead = 'live1', - liveHead = 'live1', - cmp = 'behind', - updateOk = true, - }) => { - const dir = mkdtempSync(join(tmpdir(), 'unpark-')); - const bin = join(dir, 'bin'); - mkdirSync(bin); - writeFileSync( - join(bin, 'gh'), - [ - '#!/usr/bin/env bash', - `echo "$*" >> ${JSON.stringify(join(dir, 'calls.log'))}`, - 'for a in "$@"; do case "$a" in', - ` */compare/*) printf '%s' '${cmp}'; exit 0;;`, - ` */update-branch) exit ${updateOk ? 0 : 1};;`, - 'esac; done', - 'exit 0', - ].join('\n'), - ); - chmodSync(join(bin, 'gh'), 0o755); - const out = execFileSync( - 'bash', - [ - '-c', - `set -uo pipefail\nfleet_row(){ :; }\nfor _ in x; do\n${script}\nprintf 'FELL_THROUGH'\ndone`, - ], - { - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - REPO: 'o/r', - PR: '1', - N_REVIEWS: '0', - N_COMMENTS: '0', - N_ISSUE_COMMENTS: '0', - N_FAILED_CHECKS: '0', - N_RED_NOW: '0', - HAS_CONFLICT: 'false', - LIVE_HEAD: liveHead, - HANDOFF_HEAD: handoffHead, - EFF_WM: 'wm', - ROUND: '1', - EFF_MAX_ROUNDS: '100', - }, - encoding: 'utf8', - }, - ); - const calls = existsSync(join(dir, 'calls.log')) - ? readFileSync(join(dir, 'calls.log'), 'utf8') - : ''; - rmSync(dir, { recursive: true, force: true }); - return { - unparked: /update-branch/.test(calls) && /autofix-rearm/.test(calls), - idle: out.includes('nothing new'), - }; - }; - - // Parked on the CURRENT head (handoff head == live) and behind main → merge - // current main in and re-arm; the loop re-reads the feedback next scan. - expect(run({ cmp: 'behind' })).toEqual({ unparked: true, idle: false }); - // 'diverged' (ahead AND behind) also merges main in. - expect(run({ cmp: 'diverged' })).toEqual({ unparked: true, idle: false }); - // Up to date ('ahead') → not a stale base; a genuine parking stays for a - // human, no update, no re-arm. - expect(run({ cmp: 'ahead' })).toEqual({ unparked: false, idle: true }); - // Handoff head != live head (a push landed after the parking) → the parking - // is cleared; do nothing (no compare-driven update). - expect(run({ handoffHead: 'old0', liveHead: 'live1' })).toEqual({ - unparked: false, - idle: true, - }); - // No handoff marker at all (a healthy "no changes needed" idle) → idle. - expect(run({ handoffHead: '', liveHead: 'live1' })).toEqual({ - unparked: false, - idle: true, - }); - // Behind but update-branch FAILS (PAT scope, or a race) → fail-safe: fall - // through to idle, never re-arm on an un-updated base. - expect(run({ cmp: 'behind', updateOk: false })).toEqual({ - unparked: false, - idle: true, - }); - // Both heads empty (API failures on both sides) → the -n guard must - // short-circuit; without this, "" == "" would enter the unpark block. - expect(run({ handoffHead: '', liveHead: '' })).toEqual({ - unparked: false, - idle: true, - }); - }, 20000); - - it('marks a gate-rejection parking with a head-scoped handoff marker', () => { - // The could-not-address branch (a real fix rejected, PR up to date now) sets - // the flag; a crash/timeout handoff (no fix produced) must not. - expect(reviewAddressReportStep).toMatch( - /A human should take over this PR\."\n[\s\S]{0,500}?PARKED_STALE_CANDIDATE=true/, - ); - // The report emits the head-scoped marker ONLY under that flag, so a - // crash/timeout parking is never treated as a stale-base candidate. - expect(reviewAddressReportStep).toMatch( - /if \[\[ "\$\{PARKED_STALE_CANDIDATE:-false\}" == 'true' \]\]; then\n\s*echo ""/, - ); - // The scan parses HANDOFF_HEAD head-scoped, exactly like RED_HEAD. - expect(reviewScanJob).toContain( - 'scan("")', - ); - }); - - it('parses HANDOFF_HEAD from issue comments: latest bot marker wins, non-bot and markerless ignored', () => { - const assignment = reviewScanJob.match( - / {12}HANDOFF_HEAD="\$\(jq -r[\s\S]*?ic\.json"\)"\n/, - )?.[0]; - expect(assignment).toBeTruthy(); - const script = assignment.replace(/^ {12}/gm, ''); - - const run = (comments) => { - const dir = mkdtempSync(join(tmpdir(), 'handoff-jq-')); - writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); - const out = execFileSync( - 'bash', - [ - '-c', - `set -uo pipefail\nAUTOFIX_BOT='bot'\nWORKDIR=${JSON.stringify(dir)}\n${script}\nprintf '%s' "$HANDOFF_HEAD"`, - ], - { encoding: 'utf8' }, - ); - rmSync(dir, { recursive: true, force: true }); - return out; - }; - - // No comments → empty. - expect(run([])).toBe(''); - // Bot comment with a handoff marker → the sha. - expect( - run([ - { - user: { login: 'bot' }, - body: 'handoff ', - created_at: '2026-01-01T00:00:00Z', - }, - ]), - ).toBe('abc123'); - // Non-bot comment with the same marker → ignored. - expect( - run([ - { - user: { login: 'human' }, - body: '', - created_at: '2026-01-01T00:00:00Z', - }, - ]), - ).toBe(''); - // Multiple bot markers → latest by created_at wins. - expect( - run([ - { - user: { login: 'bot' }, - body: '', - created_at: '2026-01-01T00:00:00Z', - }, - { - user: { login: 'bot' }, - body: '', - created_at: '2026-01-02T00:00:00Z', - }, - ]), - ).toBe('bbb222'); - // Bot comment without the marker → empty. - expect( - run([ - { - user: { login: 'bot' }, - body: 'just a regular comment', - created_at: '2026-01-01T00:00:00Z', - }, - ]), - ).toBe(''); - // Missing user.login field → no crash, empty. - expect( - run([ - { - body: '', - created_at: '2026-01-01T00:00:00Z', - }, - ]), - ).toBe(''); - }); - it('keeps a still-red check visible, but only once per head', () => { // A red check is a STATE, not the instant it turned red. Counting only // "failed since the watermark" made a still-failing PR invisible the