diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index cc65d6dbb2..1f86721c8c 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -2281,7 +2281,7 @@ jobs: # bot's own eval markers, and known non-actionable bot comments # (triage stages, coverage reports, legacy suggestion summaries, # force-push reminders). - BOT_COMMENT_FILTER='") ] | .[] + | select(.[1] == $win) | (.[0] | tonumber) ] + | max // 0' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)" + if [[ "$(( NEXT_ROUND - MS_LAST ))" -ge 10 ]]; then + WIN_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' + [.[] | select((.user.login // "") == $ab) + | select((.body // "") | contains("")) + or ($win == "none" and (((.body // "") | contains("win=")) | not)))] + | sort_by(.created_at) | .[] + | (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)" + if [[ -z "${WIN_HEADS}" ]]; then + # Reaching round 10+ with zero window markers means the + # parse failed (prior markers must exist to be here) — a + # fabricated all-zero census is worse than no digest. + echo "::warning::milestone census found no window markers on #${PR}; skipping the digest" + else + N_PUSHED="$(grep -c 'Addressed the latest review feedback' <<< "${WIN_HEADS}" || true)" + # This round's own marker was posted just above but ic.json + # predates it — count it in by hand. + N_PUSHED=$(( N_PUSHED + 1 )) + N_NOOP="$(grep -c 'no changes needed' <<< "${WIN_HEADS}" || true)" + # Needle matches the emitted headline verbatim — first + # lines can embed provider error text. + N_TIMEOUT="$(grep -c 'AutoFix ran out of time before finishing' <<< "${WIN_HEADS}" || true)" + # Both wordings of the gate-rejection handoff, past and + # present — the census must not silently zero when the + # headline is reworded. + N_REJECTED="$(grep -cE 'Could not (address the latest feedback|produce a passing fix)' <<< "${WIN_HEADS}" || true)" + # Every other outcome (crash, model error, gate error, + # infra) lands in a residual bucket: a window that burned + # 80% of its budget on crashes must be the LOUDEST line in + # the digest, not four zeros quieter than a healthy one. + N_TOTAL=$(( $(grep -c . <<< "${WIN_HEADS}" || true) + 1 )) + N_OTHER=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED )) + (( N_OTHER < 0 )) && N_OTHER=0 + # Base updates carry their own marker with no win= field; + # their window is recovered by timestamp (the window key IS + # the engage ack's created_at — 'none' means count all, + # and the header says so). + N_BASE="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' + [.[] | select((.user.login // "") == $ab) + | select((.body // "") | contains("")) + | select($win == "none" or ((.created_at // "") > $win))] + | length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)" + WIN_DESC='in the current window' + WIN_DESC_ZH='当前窗口' + if [[ "${WINDOW:-none}" == 'none' ]]; then + WIN_DESC='since the PR opened (no counting window yet)' + WIN_DESC_ZH='自 PR 创建以来(尚无计数窗口)' + fi + if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '📊 Takeover milestone — round %s/%s, %s. Census: %s pushed fix(es), %s no-change review(s), %s timeout(s), %s rejected attempt(s), %s other round(s) (crash / model error / gate error / infra), %s base update(s).\n\nThis many rounds deserves a human look. Options: keep going (fine — nothing changes), split or reduce the PR if rounds keep accumulating, or release takeover (remove the `%s` label or comment `%s stop`). Management continues unchanged unless you act.\n\n
\n中文说明\n\n📊 接管里程碑 —— 第 %s/%s 轮(%s)。统计:推送修复 %s 次、审阅无需改动 %s 次、超时 %s 次、验证拒绝 %s 次、其他轮次(崩溃/模型错误/门错误/infra)%s 次、base 更新 %s 次。\n\n轮次到这个量值得人工看一眼。可选:继续(无需操作);若轮次持续累积,考虑拆分或缩减 PR;或释放接管(移除 `%s` 标签或评论 `%s stop`)。不操作则托管照常继续。\n\n
\n\n' "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${WINDOW:-none}")"; then + echo "📊 milestone digest posted on #${PR} (round ${NEXT_ROUND})" + else + echo "::warning::milestone digest failed to post on PR #${PR}; the round report above already landed" + fi + fi + fi + fi + { ISSUE_REF="" [[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 094e4ebabd..85b01659e2 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -4609,6 +4609,285 @@ describe('qwen-autofix workflow', () => { expect(workflow).not.toContain('cat > "${proxy_script}"'); }); + it('posts a takeover milestone digest as rounds accumulate, with a residual bucket', () => { + // The takeover cap (100) bounds runaway but says nothing about when a + // human should step in: #7469 ground to round 12 over 7 days with the + // only budget signal buried in Actions logs. Once 10+ rounds accumulate + // since the last digest, a window-scoped census lands on the PR itself. + // Digest fires only on takeover PRs and only for PUSHED rounds… + expect(pushAndReportStep).toContain('"${OUTCOME}" == "fixed"'); + expect(pushAndReportStep).toContain( + '"${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}"', + ); + // …on a CROSSING trigger, not an equality test: failure rounds also + // advance the counter, so `push@9, crash@10, push@11` would skip an + // exact %10 forever — on exactly the failure-heavy PR the digest + // exists for. + expect(pushAndReportStep).toContain('"${NEXT_ROUND}" -ge 10'); + expect(pushAndReportStep).toContain('$(( NEXT_ROUND - MS_LAST ))'); + // Its own marker, NOT autofix-eval: every census (round, consec, + // watermark) selects on autofix-eval, so the digest must stay + // invisible to all of them; the feedback filters drop bot comments, + // so the agent never reads it as feedback either. + expect(pushAndReportStep).toContain('`, + }); + const baseC = (at) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: at, + body: '🔀 Base updated: …\n', + }); + const msC = (round, win, at) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: at, + body: `📊 …\n`, + }); + const runDigest = ( + comments, + { + nextRound = 10, + window = K, + outcome = 'fixed', + maxRounds = '100', + commentExit = 0, + } = {}, + ) => { + const dir = mkdtempSync(join(tmpdir(), 'milestone-')); + try { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); + const bin = join(dir, 'bin'); + mkdirSync(bin); + const commentBody = + commentExit === 0 + ? `printf '%s' "$7" > ${JSON.stringify(join(dir, 'digest.md'))}; exit 0` + : `exit ${commentExit}`; + writeFileSync( + join(bin, 'gh'), + `#!/usr/bin/env bash\nif [[ "$1" == 'pr' && "$2" == 'comment' ]]; then ${commentBody}; fi\nexit 1\n`, + ); + chmodSync(join(bin, 'gh'), 0o755); + const log = execFileSync( + 'bash', + ['-c', `set -euo pipefail\n${digestBlock.replace(/\n {10}/g, '\n')}`], + { + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + WORKDIR: dir, + OUTCOME: outcome, + NEXT_ROUND: String(nextRound), + MAX_ROUNDS: maxRounds, + TAKEOVER_MAX_ROUNDS: '100', + WINDOW: window, + AUTOFIX_BOT: 'qwen-code-dev-bot', + REPO: 'o/r', + PR: '1', + TAKEOVER_LABEL: 'autofix/takeover', + TAKEOVER_COMMAND: '@qwen-code /takeover', + }, + encoding: 'utf8', + }, + ); + const digestPath = join(dir, 'digest.md'); + return { + log, + body: existsSync(digestPath) ? readFileSync(digestPath, 'utf8') : '', + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + // Mixed healthy history: counts land in the right buckets, an + // old-window push and a HUMAN quoting a marker verbatim are excluded, + // both rejection wordings count, base updates window by timestamp. + const mixed = runDigest([ + evalC(HEADS.push, K, '2026-07-02T00:00:00Z'), + evalC(HEADS.push, K, '2026-07-03T00:00:00Z'), + evalC(HEADS.push, K, '2026-07-04T00:00:00Z'), + evalC(HEADS.push, K, '2026-07-05T00:00:00Z'), + evalC(HEADS.push, K, '2026-07-06T00:00:00Z'), + evalC(HEADS.noop, K, '2026-07-07T00:00:00Z'), + evalC(HEADS.timeout, K, '2026-07-08T00:00:00Z'), + evalC(HEADS.rejectedOld, K, '2026-07-09T00:00:00Z'), + evalC(HEADS.rejectedNew, K, '2026-07-10T00:00:00Z'), + evalC(HEADS.push, '2026-05-01T00:00:00Z', '2026-06-01T00:00:00Z'), + evalC(HEADS.push, K, '2026-07-11T00:00:00Z', 'some-human'), + baseC('2026-07-12T00:00:00Z'), + baseC('2026-05-02T00:00:00Z'), + ]); + expect(mixed.body).toContain( + '6 pushed fix(es), 1 no-change review(s), 1 timeout(s), 2 rejected attempt(s), 0 other round(s)', + ); + expect(mixed.body).toContain('1 base update(s)'); + expect(mixed.body).toContain('round 10/100, in the current window'); + expect(mixed.body).toContain( + '', + ); + expect(mixed.log).toContain('milestone digest posted'); + + // Failure-heavy window: crashes and gate errors land in the residual + // bucket and make it the LOUDEST line, not four zeros quieter than a + // healthy window. + const grim = runDigest([ + evalC(HEADS.push, K, '2026-07-02T00:00:00Z'), + evalC(HEADS.crash, K, '2026-07-03T00:00:00Z'), + evalC(HEADS.crash, K, '2026-07-04T00:00:00Z'), + evalC(HEADS.crash, K, '2026-07-05T00:00:00Z'), + evalC(HEADS.crash, K, '2026-07-06T00:00:00Z'), + evalC(HEADS.gate, K, '2026-07-07T00:00:00Z'), + evalC(HEADS.gate, K, '2026-07-08T00:00:00Z'), + evalC(HEADS.gate, K, '2026-07-09T00:00:00Z'), + evalC(HEADS.gate, K, '2026-07-10T00:00:00Z'), + ]); + expect(grim.body).toContain( + '2 pushed fix(es), 0 no-change review(s), 0 timeout(s), 0 rejected attempt(s), 8 other round(s)', + ); + + // Crossing trigger: a digest at round 10 suppresses round 12 but not + // round 20; a failure at the exact multiple no longer loses the digest. + const suppressed = runDigest( + [ + evalC(HEADS.push, K, '2026-07-02T00:00:00Z'), + msC(10, K, '2026-07-03T00:00:00Z'), + ], + { nextRound: 12 }, + ); + expect(suppressed.body).toBe(''); + const dueAgain = runDigest( + [ + evalC(HEADS.push, K, '2026-07-02T00:00:00Z'), + msC(10, K, '2026-07-03T00:00:00Z'), + ], + { nextRound: 20 }, + ); + expect(dueAgain.body).toContain('round 20/100'); + // An old-window milestone marker does not suppress a fresh window. + const freshWindow = runDigest( + [ + evalC(HEADS.push, K, '2026-07-02T00:00:00Z'), + msC(11, '2026-05-01T00:00:00Z', '2026-07-03T00:00:00Z'), + ], + { nextRound: 11 }, + ); + expect(freshWindow.body).toContain('round 11/100'); + + // WINDOW=none says what it counts instead of claiming a window. + const noWindow = runDigest( + [evalC(HEADS.push, null, '2026-07-02T00:00:00Z')], + { window: 'none' }, + ); + expect(noWindow.body).toContain('since the PR opened'); + + // Non-pushing outcomes and non-takeover caps never digest. + expect( + runDigest([evalC(HEADS.push, K, '2026-07-02T00:00:00Z')], { + outcome: 'noop', + }).body, + ).toBe(''); + expect( + runDigest([evalC(HEADS.push, K, '2026-07-02T00:00:00Z')], { + maxRounds: '10', + }).body, + ).toBe(''); + // A census that parses zero window markers at round 10+ posts NOTHING + // rather than a fabricated all-zero digest. + const empty = runDigest([]); + expect(empty.body).toBe(''); + expect(empty.log).toContain('skipping the digest'); + + // Best-effort is behavioral, not just string-pinned: when `gh pr + // comment` fails, the block must still return normally (no throw under + // the step's `set -e` lineage) and only warn — a good push must never + // go red over a failed digest. + const commentFailed = runDigest( + [evalC(HEADS.push, K, '2026-07-02T00:00:00Z')], + { commentExit: 1 }, + ); + expect(commentFailed.body).toBe(''); + expect(commentFailed.log).toContain( + '::warning::milestone digest failed to post on PR #1', + ); + + // Best-effort stays pinned: the success log is chained to the post + // (no unconditional "posted" after a failed comment), the failure + // path only warns. + expect(pushAndReportStep).toMatch( + /then\n\s+echo "📊 milestone digest posted/, + ); + expect(pushAndReportStep).toContain('milestone digest failed to post'); + }); + it('pushes autofix branches without rewriting remote history', () => { expect(workflow).not.toMatch(/\bgit push\b[^\n]*--force(?:-with-lease)?/); // No bare -f / +refspec force forms either. (--no-verify is NOT a force @@ -6191,13 +6470,13 @@ describe('qwen-autofix workflow', () => { expect(workflow).toContain("RETRY_COMMAND: '@qwen-code /retry'"); expect(workflow).toContain(''); expect(workflow).toContain( - '