From ff76452af6f175c47efdbc52b2d7713c21f4e115 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 17 Aug 2026 10:32:45 +0800 Subject: [PATCH 01/11] fix(autofix): make the brake's BLOCKED handoff a first-class round outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the growth brake fires, feedback.md tells the address agent to stop BLOCKED with a handoff — but the output contract only accepted address-summary.md or no-action.md, so a round that followed the instruction died as 'finished without required output file(s)', the brake's decision text was buried under a generic failure.md, the report said 'could not produce a passing fix', and the job left a red review-address check that the next scan counts as new feedback. Observed on #9222 rounds 6/7. The handoff becomes a first-class verdict end to end: run-agent.mjs honors an agent-written handoff.md (with no fix verdict) as a graceful exit the way it already honors failure.md, and shields it from the API-error retry reclassification; the verification gate reports outcome=handoff for a no-commit round with a handoff and no failure.md; finalize lets handoff pass without failing the job; the report step runs for this outcome, posts the handoff note with the eval marker (watermark advances — the feedback is consumed as evaluated), and names the stop honestly instead of reporting it as a failed fix. The skill now tells the agent exactly which file to write when the brake fires. A coexisting spec output still outranks the handoff, and failure.md coexistence keeps the failed classification, so crash paths are unchanged. --- .../run-autofix-review-verification.sh | 14 ++++++++- .github/workflows/qwen-autofix.yml | 29 ++++++++++++++++--- .qwen/skills/autofix/SKILL.md | 12 ++++++-- .qwen/skills/autofix/scripts/run-agent.mjs | 19 +++++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 369d688fa8c..2c9d6f665f9 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -354,7 +354,19 @@ run_check_no_ab 'cross-package contract verification failed' \ assert_verification_tree if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then - # No new commit. That is only legitimate as a deliberate no-action. + # No new commit. A handoff with no failure.md is the deliberate kind: the + # agent stopped under instruction (the growth-brake BLOCKED stop) and + # deferred this item to a human. Name it — "produced nothing" is what a + # crash looks like, and the report must tell the two apart. (When + # failure.md coexists, the round came through a failure path — harness + # death or a pre-verdict crash — and keeps the failed classification.) + if [[ -s "${WORKDIR}/handoff.md" && ! -s "${WORKDIR}/failure.md" ]]; then + echo "🤝 Branch unchanged with a handoff — the agent stopped under instruction and deferred this item to a human:" + cat "${WORKDIR}/handoff.md" + echo "outcome=handoff" >> "${GITHUB_OUTPUT}" + exit 0 + fi + # Otherwise no commit is only legitimate as a deliberate no-action. if [[ -s "${WORKDIR}/no-action.md" ]]; then echo "🟰 No action needed:" cat "${WORKDIR}/no-action.md" diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index d352701e9c5..13129349abe 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5768,7 +5768,14 @@ jobs: echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" fi case "${OUTCOME}" in - fixed|noop) ;; + # handoff is a deliberate verdict, not a failure: the agent + # stopped under instruction (the growth-brake BLOCKED stop) and + # deferred the item to a human. Failing the job here would leave + # a red review-address check, which the next scan counts as NEW + # feedback — turning one deliberate stop into a self-feeding + # loop. The 'Report dry-run / failure' step still posts the + # handoff comment and the eval marker for this outcome. + fixed|noop|handoff) ;; *) exit 1 ;; esac @@ -6472,7 +6479,7 @@ jobs: - name: 'Report dry-run / failure' if: |- - ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }} + ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled() || steps.final_verify.outputs.outcome == 'handoff') }} env: OUTCOME: '${{ steps.final_verify.outputs.outcome }}' COMMITTED: '${{ steps.final_verify.outputs.committed }}' @@ -6561,7 +6568,11 @@ jobs: # consumes a round. POST_HANDOFF=false if [[ "${DRY_RUN}" != "true" && "${STALE:-}" != "true" && -n "${GITHUB_TOKEN:-}" && "${OUTCOME:-unknown}" != "fixed" && "${OUTCOME:-unknown}" != "noop" ]]; then - if [[ "${OUTCOME:-unknown}" == "failed" || "${JOB_STATUS:-}" != "success" ]]; then + # handoff rounds end with a SUCCESS job status (a deliberate + # verdict), so they must trigger on the outcome itself — without + # this clause nothing would post and the loop would go silent on + # exactly the rounds that most need a visible human handoff. + if [[ "${OUTCOME:-unknown}" == "failed" || "${OUTCOME:-unknown}" == "handoff" || "${JOB_STATUS:-}" != "success" ]]; then POST_HANDOFF=true fi fi @@ -6735,6 +6746,15 @@ jobs: MARK_ROUND="${MAX_ROUNDS}" fi else + if [[ "${OUTCOME:-}" == 'handoff' ]]; then + # A deliberate stop, not a failed fix: the agent stopped + # under instruction and deferred the item to a human. No + # stale-base retry (there is no fix to re-attempt) and the + # headline says what actually happened — the old + # "could not produce a passing fix" wording reported the + # brake's decision as a failure and buried the handoff. + HEADLINE="🤖 AutoFix stopped this item under instruction and deferred it to a human (round ${MARK_ROUND}/${MAX_ROUNDS}) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + else # The gate ran and rejected the agent's fix (a build/test # failure). Before handing to a human, check whether the PR is # merely BEHIND main: a build that fails on something main @@ -6804,6 +6824,7 @@ jobs: fi HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS})${GATE_CLAUSE}. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." fi + fi fi elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then # NEWEST is empty because Prepare never RAN TO A VERDICT — an @@ -6906,7 +6927,7 @@ jobs: done <<< "${PRIOR_HEADS}" if [[ "${CONSEC_FAIL}" -ge "${CONSECUTIVE_FAILURE_CAP}" ]]; then MARK_ROUND="${MAX_ROUNDS}" - HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that failed to push anything (timeouts and/or gate rejections). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that pushed nothing (failed rounds, timeouts, gate rejections, or stops under instruction). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." fi # CUMULATIVE timeout breaker — the sibling of the consecutive # one above, for the failure shape it cannot see: timeouts diff --git a/.qwen/skills/autofix/SKILL.md b/.qwen/skills/autofix/SKILL.md index af441e4cfc0..2bc10c5b4c5 100644 --- a/.qwen/skills/autofix/SKILL.md +++ b/.qwen/skills/autofix/SKILL.md @@ -359,8 +359,14 @@ silently overriding or silently complying. handoff that names the decision and lays out the options — split the PR (land the core, track the remaining findings as follow-up issues), redesign, or accept the current state with the tail deferred — plus your recommendation. - Continuing to patch, or deciding the split yourself, is exactly the wrong - move; the call is the maintainer's. + Write that handoff to `/handoff.md` — English-only, no details + block — naming the decision, the options, your recommendation, and what was + tried; then stop without writing anything else: no commit, no + `address-summary.md`, no `no-action.md`, no `failure.md`. The harness + recognizes a handoff with no fix verdict as a deliberate deferral: the round + ends cleanly, the note is posted to the PR, and the item waits for the + maintainer instead of being re-run. Continuing to patch, or deciding the + split yourself, is exactly the wrong move; the call is the maintainer's. - Needs a maintainer's decision: a finding that turns on a judgment that is NOT yours to make — a product or scope tradeoff (is this acceptable for v1? should the PR be split?), two reviewers asking for opposite things, or whether @@ -506,5 +512,7 @@ Finish with exactly one outcome: answered when you escalated. Each body is bilingual per GitHub Actions Rules. Omit the file when every inline finding was resolved. - No change: write `/no-action.md` (bilingual per GitHub Actions Rules). +- Stopped by the growth brake: write `/handoff.md` per the + not-converging rule (English-only, no details block) — and commit nothing. - The GitHub Actions Rules' objective stop condition applies: write `/failure.md` and do not commit. diff --git a/.qwen/skills/autofix/scripts/run-agent.mjs b/.qwen/skills/autofix/scripts/run-agent.mjs index d7c971438f7..e606e99874c 100755 --- a/.qwen/skills/autofix/scripts/run-agent.mjs +++ b/.qwen/skills/autofix/scripts/run-agent.mjs @@ -497,7 +497,10 @@ const apiErrorWithoutVerdict = result.status === 0 && result.apiError && !hasOutputVerdict && - !existsSync(file(options.workdir, 'failure.md')); + !existsSync(file(options.workdir, 'failure.md')) && + // A handoff is a verdict too: an agent that reached its BLOCKED stop must + // not be reclassified as a bare API blip by an error render in the tail. + !existsSync(file(options.workdir, 'handoff.md')); if ( result.error || result.signal || @@ -586,6 +589,20 @@ if (existsSync(file(options.workdir, 'failure.md'))) { process.exit(0); } +// A handoff the AGENT itself wrote — the growth-brake BLOCKED stop: the +// feedback told it to defer to a human, so the round ends without a fix +// verdict. Same standing as failure.md: a real, human-facing outcome, NOT +// the missing-output failure class (which once reported a deliberate stop as +// "finished without required output file(s)" and buried the brake's decision +// under a generic failure.md). Honored only when no spec output exists: a +// round that also wrote address-summary.md or no-action.md published a +// result, and that verdict outranks the handoff. +if (!hasOutputVerdict && existsSync(file(options.workdir, 'handoff.md'))) { + const content = readFileSync(file(options.workdir, 'handoff.md'), 'utf8'); + console.error(`Autofix agent wrote handoff.md:\n${content}`); + process.exit(0); +} + if (spec.exclusiveOutput && presentOutputs.length > 1) { const message = `Autofix agent wrote mutually exclusive output files: ${presentOutputs.join(', ')}.`; writeFailure(options.workdir, message); From ed1ea96da73303720b9a518c274e7c82b9dc4e77 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 10:07:20 +0000 Subject: [PATCH 02/11] fix(autofix): align the handoff outcome's consumers and pins with its contract (#9297) Review found the new handoff outcome breaking two pinned helper tests (stale breaker-headline wording, unclassified headline in the fleet-shepherd contract test), misreporting handoff rounds in the status-comment finalize step, and leaving the whole handoff chain unpinned against mutation. - Update the breaker headline pin to the PR's reworded headline. - Classify the handoff headline as transient in the shepherd contract test and drop its "AutoFix stopped" prefix so the shepherd's terminal-only REASON regex cannot capture a transient stop (the shepherd workflow itself stays outside this round's footprint). - Include handoff in the Finalize-status published-report branch. - Give deliberate stops their own takeover-digest census bucket instead of the residual crash/infra bucket (EN + ZH). - Neutralize :: workflow commands at the two new handoff echo sites. - Use the runner's non-empty missing() convention for handoff.md so an empty file cannot read as a verdict in one layer and not the other. - Correct the run-agent.mjs precedence comment: when a handoff coexists with a spec output, the gate (handoff branch first) decides the round, matching the documented "handoff + no-action -> handoff" contract. - Pin the handoff chain where its siblings are pinned: finalize replay, POST_HANDOFF replay, mark/headline replays, the gate's no-commit decision table, the stub-runner handoff/empty/API-error cases, the report-step if-clause, and the census needle-to-emit cross-pins. --- .../run-autofix-review-verification.sh | 5 +- .github/workflows/qwen-autofix.yml | 21 ++- .qwen/skills/autofix/scripts/run-agent.mjs | 24 ++- scripts/tests/qwen-autofix-workflow.test.js | 173 +++++++++++++++++- .../qwen-fleet-shepherd-workflow.test.js | 5 +- 5 files changed, 208 insertions(+), 20 deletions(-) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 2c9d6f665f9..43c810b675d 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -362,7 +362,10 @@ if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then # death or a pre-verdict crash — and keeps the failed classification.) if [[ -s "${WORKDIR}/handoff.md" && ! -s "${WORKDIR}/failure.md" ]]; then echo "🤝 Branch unchanged with a handoff — the agent stopped under instruction and deferred this item to a human:" - cat "${WORKDIR}/handoff.md" + # Agent-written content: a line-start `::` would be parsed as a workflow + # command (::error::, ::add-mask::), the same reason 'Show run artifacts' + # neutralizes these files. + sed 's/::/;;/g' "${WORKDIR}/handoff.md" echo "outcome=handoff" >> "${GITHUB_OUTPUT}" exit 0 fi diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 13129349abe..5fb181a1e4f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -6432,12 +6432,16 @@ jobs: # 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)" + # The brake's deliberate stop — its own bucket, not the + # residual crash bucket: it tells the maintainer a human + # decision is already waiting on this PR. + N_HANDOFF="$(grep -c 'deferred this item to a human under instruction' <<< "${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=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED - N_HANDOFF )) (( 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 @@ -6454,7 +6458,7 @@ jobs: 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 + 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 deliberate stop(s) under instruction (deferred to a human), %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 次、按指示有意停止(移交人工)%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_HANDOFF}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_HANDOFF}" "${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" @@ -6753,7 +6757,11 @@ jobs: # headline says what actually happened — the old # "could not produce a passing fix" wording reported the # brake's decision as a failure and buried the handoff. - HEADLINE="🤖 AutoFix stopped this item under instruction and deferred it to a human (round ${MARK_ROUND}/${MAX_ROUNDS}) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + # Wording guard: no "🤖 AutoFix stopped" prefix — the fleet + # shepherd's REASON regex reads that as a TERMINAL stop + # reason, and this stop is transient (the loop stays + # engaged); the shepherd contract test pins the distinction. + HEADLINE="🤖 AutoFix deferred this item to a human under instruction (round ${MARK_ROUND}/${MAX_ROUNDS}) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." else # The gate ran and rejected the agent's fix (a build/test # failure). Before handing to a human, check whether the PR is @@ -7161,9 +7169,10 @@ jobs: if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" fi - # 'fixed'/'noop' are the two outcomes that published a round report; - # anything else means the round stopped before publishing one. - if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' ]]; then + # 'fixed'/'noop'/'handoff' are the outcomes that published a round + # report — handoff posts its note + eval marker from the report + # step; anything else means the round stopped before publishing one. + if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' ]]; then EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")" ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")" else diff --git a/.qwen/skills/autofix/scripts/run-agent.mjs b/.qwen/skills/autofix/scripts/run-agent.mjs index e606e99874c..48dde3dabf9 100755 --- a/.qwen/skills/autofix/scripts/run-agent.mjs +++ b/.qwen/skills/autofix/scripts/run-agent.mjs @@ -493,6 +493,12 @@ const presentOutputs = spec.outputs.filter( const hasOutputVerdict = spec.anyOutput ? presentOutputs.length > 0 : missingOutputs.length === 0; +// A zero-byte handoff.md is not a verdict — the same non-empty convention +// missing() and the gate's -s checks apply, so the two layers cannot +// classify an empty file oppositely. +const hasHandoffVerdict = !missing(options.workdir, ['handoff.md']).includes( + 'handoff.md', +); const apiErrorWithoutVerdict = result.status === 0 && result.apiError && @@ -500,7 +506,7 @@ const apiErrorWithoutVerdict = !existsSync(file(options.workdir, 'failure.md')) && // A handoff is a verdict too: an agent that reached its BLOCKED stop must // not be reclassified as a bare API blip by an error render in the tail. - !existsSync(file(options.workdir, 'handoff.md')); + !hasHandoffVerdict; if ( result.error || result.signal || @@ -594,12 +600,18 @@ if (existsSync(file(options.workdir, 'failure.md'))) { // verdict. Same standing as failure.md: a real, human-facing outcome, NOT // the missing-output failure class (which once reported a deliberate stop as // "finished without required output file(s)" and buried the brake's decision -// under a generic failure.md). Honored only when no spec output exists: a -// round that also wrote address-summary.md or no-action.md published a -// result, and that verdict outranks the handoff. -if (!hasOutputVerdict && existsSync(file(options.workdir, 'handoff.md'))) { +// under a generic failure.md). Honored only when no spec output exists: if +// address-summary.md or no-action.md coexists, the runner exits on that +// verdict, and the GATE decides the round — its handoff branch runs before +// the no-action branch, so a deliberate stop outranks a co-written +// no-change verdict and cannot close silently as "no action needed". +if (!hasOutputVerdict && hasHandoffVerdict) { const content = readFileSync(file(options.workdir, 'handoff.md'), 'utf8'); - console.error(`Autofix agent wrote handoff.md:\n${content}`); + // Neutralize `::` workflow commands in agent-written content before it + // reaches the step log ('Show run artifacts' does the same). + console.error( + `Autofix agent wrote handoff.md:\n${content.replaceAll('::', ';;')}`, + ); process.exit(0); } diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index a7b150d6dfd..2f3dc8e7aca 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -9930,10 +9930,14 @@ exit 1 const emitRejected = reviewAddressReportStep.match( /HEADLINE="(🤖 Could not (?:address the latest feedback|produce a passing fix)[^"]*)"/, )?.[1]; + const emitHandoff = reviewAddressReportStep.match( + /HEADLINE="(🤖 AutoFix deferred this item to a human under instruction[^"]*)"/, + )?.[1]; expect(emitPush).toBeTruthy(); expect(emitNoop).toBeTruthy(); expect(emitTimeout).toBeTruthy(); expect(emitRejected).toBeTruthy(); + expect(emitHandoff).toBeTruthy(); const needlePushed = digestBlock.match(/N_PUSHED=.*grep -c '([^']*)'/)?.[1]; const needleNoop = digestBlock.match(/N_NOOP=.*grep -c '([^']*)'/)?.[1]; const needleTimeout = digestBlock.match( @@ -9942,14 +9946,19 @@ exit 1 const needleRejected = digestBlock.match( /N_REJECTED=.*grep -cE '([^']*)'/, )?.[1]; + const needleHandoff = digestBlock.match( + /N_HANDOFF=.*grep -c '([^']*)'/, + )?.[1]; expect(needlePushed).toBeTruthy(); expect(needleNoop).toBeTruthy(); expect(needleTimeout).toBeTruthy(); expect(needleRejected).toBeTruthy(); + expect(needleHandoff).toBeTruthy(); expect(emitPush).toContain(needlePushed); expect(emitNoop).toContain(needleNoop); expect(`🤖 AutoFix ${emitTimeout}`).toContain(needleTimeout); expect(emitRejected).toMatch(new RegExp(needleRejected)); + expect(emitHandoff).toContain(needleHandoff); const HEADS = { push: '🤖 Addressed the latest review feedback (round 2/100). What changed…', noop: '🤖 Reviewed the latest feedback — no changes needed. Why…', @@ -9962,6 +9971,8 @@ exit 1 crash: '🤖 AutoFix crashed before it could evaluate the feedback (attempt 2/100) — it will retry on the next scan.', gate: '🤖 AutoFix hit a verification-gate error before reaching a verdict (attempt 3/100) — it will retry on the next scan.', + handoff: + "🤖 AutoFix deferred this item to a human under instruction (round 3/100) — the agent's handoff note below names the decision and the options.", }; const K = '2026-07-01T00:00:00Z'; const evalC = (head, win, at, login = 'qwen-code-dev-bot') => ({ @@ -10048,13 +10059,14 @@ exit 1 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.handoff, K, '2026-07-11T12: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)', + '6 pushed fix(es), 1 no-change review(s), 1 timeout(s), 2 rejected attempt(s), 1 deliberate stop(s) under instruction (deferred to a human), 0 other round(s)', ); expect(mixed.body).toContain('1 base update(s)'); expect(mixed.body).toContain('round 10/100, in the current window'); @@ -10078,7 +10090,7 @@ exit 1 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)', + '2 pushed fix(es), 0 no-change review(s), 0 timeout(s), 0 rejected attempt(s), 0 deliberate stop(s) under instruction (deferred to a human), 8 other round(s)', ); // Crossing trigger: a digest at round 10 suppresses round 12 but not @@ -12788,6 +12800,11 @@ exit 1 "needs.route.outputs.dry_run == 'true'", ); expect(reviewAddressReportStep).toContain('failure() || cancelled()'); + // The handoff outcome also routes here: a green, deliberate stop must + // still post its report + eval marker, never go silent. + expect(reviewAddressReportStep).toContain( + "steps.final_verify.outputs.outcome == 'handoff'", + ); expect(reviewAddressReportStep).not.toContain( "steps.verify.outputs.outcome == 'failed'", ); @@ -12967,6 +12984,13 @@ exit 1 status: 0, written: expect.stringContaining('outcome=noop'), }); + // A handoff is a deliberate verdict like fixed/noop, not a failure: the + // job must end green so the red-check scan does not count the stop as + // new feedback and re-arm the loop against itself. + expect(run({ FIRST_OUTCOME: 'handoff' })).toMatchObject({ + status: 0, + written: expect.stringContaining('outcome=handoff'), + }); expect( run({ FIRST_OUTCOME: 'failed', @@ -13236,7 +13260,12 @@ exit 1 expect(statusStep).toContain('^[0-9]+$'); } // Tells a round that published a report from one that died before it. - expect(finalizeStatusCommentStep).toContain("== 'fixed'"); + // handoff publishes one too (the handoff note + eval marker), so it + // reads "finished", never "ended without publishing a report" above its + // own report. + expect(finalizeStatusCommentStep).toContain( + '[[ "${OUTCOME:-}" == \'fixed\' || "${OUTCOME:-}" == \'noop\' || "${OUTCOME:-}" == \'handoff\' ]]', + ); expect(finalizeStatusCommentStep).toContain( 'ended without publishing a report', ); @@ -13989,7 +14018,7 @@ exit 1 // — an `if true` mutation on the timeout guard flips the headline and // must fail here. expect(bothCapped.headline).toContain( - 'consecutive rounds that failed to push', + 'consecutive rounds that pushed nothing', ); expect(bothCapped.headline).not.toContain('time-budget exhaustions'); // Pin the census greps to the actual emit line: the timeout CAUSE text @@ -15346,6 +15375,12 @@ exit 1 JOB_STATUS: 'failure', }), ).toBe('false'); + // A handoff round ends GREEN (a deliberate verdict), so the trigger must + // key on the outcome itself — without that clause nothing would post and + // the loop would go silent on exactly the rounds that need a human. + expect( + runPostHandoff({ ...base, OUTCOME: 'handoff', JOB_STATUS: 'success' }), + ).toBe('true'); expect(reviewAddressReportStep).toContain( "STALE: '${{ steps.prepare.outputs.stale }}'", ); @@ -15421,6 +15456,17 @@ exit 1 expect( runMark({ NEWEST: '', WATERMARK: '', PREPARE_OUTCOME: 'failure' }), ).toBe(`${SENTINEL}|5`); + // 4. A deliberate handoff (the brake's BLOCKED stop): the feedback WAS + // read, so the watermark advances and the round stays NON-terminal — + // the loop stays engaged for new feedback and base conflicts. + expect( + runMark({ + NEWEST: '2026-07-16T00:00:00Z', + DETAIL_FILE: '/tmp/handoff.md', + OUTCOME: 'handoff', + JOB_STATUS: 'success', + }), + ).toBe('2026-07-16T00:00:00Z|3'); // The no-output-crash HEADLINE must only promise a retry when one will // actually happen: at the final attempt (MARK_ROUND == MAX_ROUNDS) the @@ -15442,6 +15488,20 @@ exit 1 }, encoding: 'utf8', }); + const handoffHead = runHeadline({ + ROUND: '2', + DETAIL_FILE: '/tmp/handoff.md', + OUTCOME: 'handoff', + JOB_STATUS: 'success', + }); + expect(handoffHead).toContain( + 'deferred this item to a human under instruction', + ); + expect(handoffHead).toContain('The loop stays engaged'); + // Wording guard: no "🤖 AutoFix stopped" prefix — the fleet shepherd's + // REASON regex reads that as a TERMINAL stop reason, and this stop is + // transient. The shepherd contract test pins the same distinction. + expect(handoffHead).not.toContain('AutoFix stopped'); const midCrash = runHeadline({ ROUND: '2' }); // MARK_ROUND=3 < 5 expect(midCrash).toContain('it will retry on the next scan'); expect(midCrash).not.toContain('Run log:'); @@ -16329,6 +16389,54 @@ exit 1 }); }); + it('logs handoff.md content when the agent writes it and exits 0', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + "writeFileSync(`${workdir}/handoff.md`, 'needs a maintainer decision\\n');", + ]); + + const result = runAddressReview(dir, stub); + expect(result.status).toBe(0); + expect(result.stderr).toContain('handoff.md:'); + expect(result.stderr).toContain('needs a maintainer decision'); + expect(existsSync(join(dir, 'failure.md'))).toBe(false); + }); + }); + + it('treats an empty handoff.md as no verdict, like the gate', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + "writeFileSync(`${workdir}/handoff.md`, '');", + ]); + + const result = runAddressReview(dir, stub); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + 'finished without required output file(s)', + ); + }); + }); + + it('keeps an API-error tail from reclassifying a handoff verdict', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + `process.stdout.write(${JSON.stringify( + qwenResultLine({ result: '[API Error: 429 quota exceeded]' }), + )});`, + "writeFileSync(`${workdir}/handoff.md`, 'needs a maintainer decision\\n');", + 'process.exit(0);', + ]); + + const result = runAddressReview(dir, stub); + expect(result.status).toBe(0); + expect(existsSync(join(dir, 'failure.md'))).toBe(false); + expect(existsSync(join(dir, 'agent-api-error'))).toBe(false); + }); + }); + it('rejects mutually exclusive address-review output files', () => { withRunnerDir((dir) => { writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); @@ -16423,6 +16531,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () baselineNoIdentity = false, trackedDirt = false, commFail = false, + workdirFiles = { 'address-summary.md': 'summary\n' }, }) => { const dir = mkdtempSync(join(tmpdir(), 'gate-ab-')); try { @@ -16567,7 +16676,9 @@ describe('review verification gate: baseline A/B on deterministic rejection', () ); const workdir = join(dir, 'wd'); mkdirSync(workdir); - writeFileSync(join(workdir, 'address-summary.md'), 'summary\n'); + for (const [name, content] of Object.entries(workdirFiles)) { + writeFileSync(join(workdir, name), content); + } const outFile = join(dir, 'gh-output'); writeFileSync(outFile, ''); @@ -16874,6 +16985,58 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(r.stdout).not.toContain('Baseline A/B'); }); + it('classifies an unchanged branch by its verdict files (handoff contract)', () => { + // The no-commit decision table, in the gate's own precedence order: a + // handoff outranks a co-written no-action verdict (a BLOCKED stop must + // not close silently as "no changes needed"), failure.md keeps the crash + // classification, and a 0-byte handoff.md is not a verdict — the same + // non-empty convention the runner's missing() helper applies. + const handoff = runGate({ + agentCommit: false, + workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, + }); + expect(handoff.status).toBe(0); + expect(handoff.outputs).toContain('outcome=handoff'); + + const handoffBeatsNoAction = runGate({ + agentCommit: false, + workdirFiles: { + 'handoff.md': 'needs a maintainer decision\n', + 'no-action.md': 'nothing to change\n', + }, + }); + expect(handoffBeatsNoAction.status).toBe(0); + expect(handoffBeatsNoAction.outputs).toContain('outcome=handoff'); + + const failureBeatsHandoff = runGate({ + agentCommit: false, + workdirFiles: { + 'handoff.md': 'needs a maintainer decision\n', + 'failure.md': 'crashed before finishing\n', + }, + }); + expect(failureBeatsHandoff.status).toBe(1); + expect(failureBeatsHandoff.outputs).toContain('outcome=failed'); + + const emptyHandoff = runGate({ + agentCommit: false, + workdirFiles: { 'handoff.md': '' }, + }); + expect(emptyHandoff.status).toBe(1); + expect(emptyHandoff.outputs).toContain('outcome=failed'); + + const noAction = runGate({ + agentCommit: false, + workdirFiles: { 'no-action.md': 'nothing to change\n' }, + }); + expect(noAction.status).toBe(0); + expect(noAction.outputs).toContain('outcome=noop'); + + const nothing = runGate({ agentCommit: false, workdirFiles: {} }); + expect(nothing.status).toBe(1); + expect(nothing.outputs).toContain('outcome=failed'); + }); + it('never A/Bs the dist-coupled and stdin-fed checks', () => { // schema (round-built core dist), contracts (drained stdin), and now // typecheck (sdk-typescript resolves core d.ts from dist) are exempt. diff --git a/scripts/tests/qwen-fleet-shepherd-workflow.test.js b/scripts/tests/qwen-fleet-shepherd-workflow.test.js index a3148790708..c27f2fd008f 100644 --- a/scripts/tests/qwen-fleet-shepherd-workflow.test.js +++ b/scripts/tests/qwen-fleet-shepherd-workflow.test.js @@ -1566,13 +1566,14 @@ exit 1`; (h) => h.includes('a setup step failed') || h.includes('will retry on the next scan') || - h.includes('Could not produce a passing fix for this feedback'), + h.includes('Could not produce a passing fix for this feedback') || + h.includes('deferred this item to a human under instruction'), ); const unclassified = headlines.filter( (h) => !terminal.includes(h) && !transient.includes(h), ); expect(terminal.length).toBe(5); - expect(transient).toHaveLength(4); + expect(transient).toHaveLength(5); expect(unclassified).toEqual([]); const matches = (h) => execFileSync('jq', ['-rn', '--arg', 'b', h, `$b | test("${reasonRe}")`], { From 5c766ff5ff7b9c8509ce423f5612c0bf1e9cecc2 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 19:07:57 +0000 Subject: [PATCH 03/11] fix(autofix): classify a no-commit handoff before the gate's structural checks (#9297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review proved the new handoff classification unreachable exactly where the brake fires: the structural pre-checks (core rebuild, settings schema, contracts) judge the PR's own diff and reject before the no-commit fork, and the growth brake fires on precisely the red PRs whose diff trips them. A compliant handoff (no commit, only handoff.md) then classified as a retryable failure, so the repair pass deleted handoff.md and could commit against the brake's explicit stop — the self-feeding loop the handoff exists to prevent. Reproduced with the real gate script: schema-check-fail + no-commit handoff exited 1 with no outcome=handoff. Move the no-commit handoff classification above the structural checks (right after the failure.md exits, which keep their precedence). A handoff claims nothing — acted=false, deferred to a human — so the checks' false-no-action rationale does not apply, and the retryable/ repair machinery must never engage on a round the brake told to stop. The no-op fork reverts to no-action-only classification. - Add a gate test: stale schema + no commit + handoff.md classifies outcome=handoff, exit 0, no retryable (fails on the pre-fix gate). - Pin the handoff-note :: workflow-command neutralization in both layers (the gate's sed and the runner's replaceAll), which review showed were surviving mutations. --- .../run-autofix-review-verification.sh | 41 +++++++++++-------- scripts/tests/qwen-autofix-workflow.test.js | 35 +++++++++++++++- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 43c810b675d..62ea25ab85a 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -75,6 +75,26 @@ if [[ -f "${WORKDIR}/failure.md" ]]; then exit 1 fi +# No-commit brake handoff, classified BEFORE the structural checks below: +# those judge the PR's OWN diff (core rebuild, schema freshness, contracts) +# and reject_fix on failure, and the growth brake fires on exactly the red +# PRs whose diff trips them. A compliant handoff commits nothing, so +# running the checks first would reclassify it as a retryable failure — +# the repair pass would delete handoff.md and commit against the brake's +# stop. A handoff claims nothing (acted=false, deferred to a human), so +# the checks' false-no-action rationale does not apply. failure.md +# coexistence keeps the failed classification via the exits above. +if git diff --quiet "origin/${BRANCH}...${BRANCH}" \ + && [[ -s "${WORKDIR}/handoff.md" ]]; then + echo "🤝 Branch unchanged with a handoff — the agent stopped under instruction and deferred this item to a human:" + # Agent-written content: a line-start `::` would be parsed as a workflow + # command (::error::, ::add-mask::), the same reason 'Show run artifacts' + # neutralizes these files. + sed 's/::/;;/g' "${WORKDIR}/handoff.md" + echo "outcome=handoff" >> "${GITHUB_OUTPUT}" + exit 0 +fi + # Convention: hooks are severed at EVERY host checkout of the PR # branch (no secret sits in this step's env, but a post-checkout # hook still runs branch code on the host). @@ -337,7 +357,8 @@ fi # no-op/unchanged return: on a stale-schema PR the agent can wrongly # write no-action.md, and without this the no-op path would report the # feedback as evaluated (acted=false) while CI stays red — the exact bug -# this PR fixes. So it runs on EVERY path. The gate is shared with the +# this PR fixes. So it runs on every path but the no-commit handoff, +# which claims nothing and exits above. The gate is shared with the # issue-fix verify step (rationale + the generator crash guard live in # the script); the write is on a tracked file compared by `git status`, # not the commit-level no-op git-diff below, and it is restored on @@ -354,22 +375,8 @@ run_check_no_ab 'cross-package contract verification failed' \ assert_verification_tree if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then - # No new commit. A handoff with no failure.md is the deliberate kind: the - # agent stopped under instruction (the growth-brake BLOCKED stop) and - # deferred this item to a human. Name it — "produced nothing" is what a - # crash looks like, and the report must tell the two apart. (When - # failure.md coexists, the round came through a failure path — harness - # death or a pre-verdict crash — and keeps the failed classification.) - if [[ -s "${WORKDIR}/handoff.md" && ! -s "${WORKDIR}/failure.md" ]]; then - echo "🤝 Branch unchanged with a handoff — the agent stopped under instruction and deferred this item to a human:" - # Agent-written content: a line-start `::` would be parsed as a workflow - # command (::error::, ::add-mask::), the same reason 'Show run artifacts' - # neutralizes these files. - sed 's/::/;;/g' "${WORKDIR}/handoff.md" - echo "outcome=handoff" >> "${GITHUB_OUTPUT}" - exit 0 - fi - # Otherwise no commit is only legitimate as a deliberate no-action. + # No new commit. That is only legitimate as a deliberate no-action; the + # no-commit handoff was classified before the structural checks above. if [[ -s "${WORKDIR}/no-action.md" ]]; then echo "🟰 No action needed:" cat "${WORKDIR}/no-action.md" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 2f3dc8e7aca..9a539341126 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -16393,13 +16393,17 @@ exit 1 withRunnerDir((dir) => { writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); const stub = writeWorkdirStub(dir, [ - "writeFileSync(`${workdir}/handoff.md`, 'needs a maintainer decision\\n');", + "writeFileSync(`${workdir}/handoff.md`, '::error::forged\\nneeds a maintainer decision\\n');", ]); const result = runAddressReview(dir, stub); expect(result.status).toBe(0); expect(result.stderr).toContain('handoff.md:'); expect(result.stderr).toContain('needs a maintainer decision'); + // Agent-written content reaches the privileged job's step log: pin + // the `::` workflow-command neutralization (removing it must fail). + expect(result.stderr).toContain(';;error;;forged'); + expect(result.stderr).not.toContain('::error::forged'); expect(existsSync(join(dir, 'failure.md'))).toBe(false); }); }); @@ -17035,6 +17039,35 @@ describe('review verification gate: baseline A/B on deterministic rejection', () const nothing = runGate({ agentCommit: false, workdirFiles: {} }); expect(nothing.status).toBe(1); expect(nothing.outputs).toContain('outcome=failed'); + + // The handoff note is agent-written and lands in the privileged job's + // step log: a line-start `::` would parse as a workflow command, so the + // gate neutralizes it. Pin the mutation — removing the sed must fail. + const neutralized = runGate({ + agentCommit: false, + workdirFiles: { 'handoff.md': '::error::forged\n' }, + }); + expect(neutralized.status).toBe(0); + expect(neutralized.outputs).toContain('outcome=handoff'); + expect(neutralized.stdout).toContain(';;error;;forged'); + expect(neutralized.stdout).not.toContain('::error::forged'); + }); + + it('classifies a no-commit handoff before the structural checks', () => { + // The growth brake fires on red, non-converging PRs — exactly the + // diffs that trip the structural pre-checks (a stale schema here). A + // compliant handoff commits nothing, so classifying it AFTER those + // checks would let the stale schema reject the round as retryable and + // engage the repair pass — committing after the brake said commit + // nothing. + const r = runGate({ + agentCommit: false, + schemaFail: true, + workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, + }); + expect(r.status).toBe(0); + expect(r.outputs).toContain('outcome=handoff'); + expect(r.outputs).not.toContain('retryable=true'); }); it('never A/Bs the dist-coupled and stdin-fed checks', () => { From e5a31065e77c7ddc352c696dfe8802ddd9ec012c Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 21:17:02 +0000 Subject: [PATCH 04/11] fix(autofix): reject a no-commit handoff written over a dirty workspace (#9297) --- .../run-autofix-review-verification.sh | 15 ++++++ scripts/tests/qwen-autofix-workflow.test.js | 54 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 62ea25ab85a..f5c9d4d3cf8 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -75,6 +75,21 @@ if [[ -f "${WORKDIR}/failure.md" ]]; then exit 1 fi +# A handoff claims the round changed NOTHING — dirt beside it is a +# brake-violating partial patch (otherwise reported as a clean stop and +# discarded silently with the runner), and untracked leftovers would trip +# the NEXT round's dirty assert on the persistent pool. The ref-level +# commit diff below is blind to both. Rejected the same non-retryable way +# failure.md+dirty is above: a retryable rejection would engage the repair +# pass, which deletes handoff.md and may commit against the brake. +if [[ -s "${WORKDIR}/handoff.md" && -n "$(git status --porcelain)" ]]; then + echo "❌ Agent wrote handoff.md after leaving a dirty workspace:" + git status --short + sed 's/::/;;/g' "${WORKDIR}/handoff.md" + echo "outcome=failed" >> "${GITHUB_OUTPUT}" + exit 1 +fi + # No-commit brake handoff, classified BEFORE the structural checks below: # those judge the PR's OWN diff (core rebuild, schema freshness, contracts) # and reject_fix on failure, and the growth brake fires on exactly the red diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 9a539341126..28143d951cd 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -16534,6 +16534,7 @@ describe('review verification gate: baseline A/B on deterministic rejection', () noIdentity = false, baselineNoIdentity = false, trackedDirt = false, + dirtyTree = '', commFail = false, workdirFiles = { 'address-summary.md': 'summary\n' }, }) => { @@ -16579,6 +16580,13 @@ describe('review verification gate: baseline A/B on deterministic rejection', () g('echo agent > f.txt && git commit -qam agent'); } } + if (dirtyTree === 'tracked') { + // Uncommitted tracked edit left beside the verdict files — the + // ref-level commit diff is blind to it. + g('echo dirt >> f.txt'); + } else if (dirtyTree === 'untracked') { + g('echo leftover > leftover.txt'); + } const shaOf = (ref) => g(`git rev-parse ${ref}`).trim(); const failShas = failAt.map(shaOf).join(' '); const baselineSha = shaOf('origin/feature'); @@ -17040,6 +17048,19 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(nothing.status).toBe(1); expect(nothing.outputs).toContain('outcome=failed'); + // Pins the guard's committed-side clause: with a round commit present, + // the handoff branch must NOT fire even though handoff.md exists — the + // round falls through to the address-summary gate instead. Deleting the + // `git diff --quiet` clause flips this to exit 0 / outcome=handoff, + // skipping every structural check on the committed diff. + const committedWithHandoff = runGate({ + agentCommit: true, + workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, + }); + expect(committedWithHandoff.status).toBe(1); + expect(committedWithHandoff.outputs).toContain('outcome=failed'); + expect(committedWithHandoff.outputs).not.toContain('outcome=handoff'); + // The handoff note is agent-written and lands in the privileged job's // step log: a line-start `::` would parse as a workflow command, so the // gate neutralizes it. Pin the mutation — removing the sed must fail. @@ -17053,6 +17074,39 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(neutralized.stdout).not.toContain('::error::forged'); }); + it('rejects a handoff written over a dirty workspace, non-retryably', () => { + // A handoff claims the round deliberately changed NOTHING; dirt beside + // it is a brake-violating partial patch that would otherwise be + // reported as a clean stop and die silently with the runner. Both dirt + // shapes matter: the ref-level commit diff is blind to uncommitted + // tracked edits AND untracked files. + for (const dirtyTree of ['tracked', 'untracked']) { + const r = runGate({ + agentCommit: false, + dirtyTree, + workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, + }); + expect(r.status).toBe(1); + expect(r.outputs).toContain('outcome=failed'); + expect(r.outputs).not.toContain('outcome=handoff'); + // The rejection must stay non-retryable: retryable=true engages the + // repair pass, which deletes handoff.md and may commit against the + // brake's stop. + expect(r.outputs).not.toContain('retryable=true'); + } + + // Without handoff.md the dirt still takes the ordinary dirty assert — + // retryable, so the new guard has not swallowed that path. + const dirtWithoutHandoff = runGate({ + agentCommit: false, + dirtyTree: 'tracked', + workdirFiles: {}, + }); + expect(dirtWithoutHandoff.status).toBe(1); + expect(dirtWithoutHandoff.outputs).toContain('retryable=true'); + expect(dirtWithoutHandoff.outputs).not.toContain('outcome=handoff'); + }); + it('classifies a no-commit handoff before the structural checks', () => { // The growth brake fires on red, non-converging PRs — exactly the // diffs that trip the structural pre-checks (a stale schema here). A From c0f1c75f88deac4d12089e8af36e89a0a7c3627f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 18 Aug 2026 00:18:40 +0000 Subject: [PATCH 05/11] fix(autofix): report a dirty-handoff rejection honestly, not as a failed fix (#9297) --- .../run-autofix-review-verification.sh | 12 +- .github/workflows/qwen-autofix.yml | 30 +++-- scripts/tests/qwen-autofix-workflow.test.js | 106 +++++++++++++++++- .../qwen-fleet-shepherd-workflow.test.js | 5 +- 4 files changed, 137 insertions(+), 16 deletions(-) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index f5c9d4d3cf8..8145bca1619 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -79,14 +79,18 @@ fi # brake-violating partial patch (otherwise reported as a clean stop and # discarded silently with the runner), and untracked leftovers would trip # the NEXT round's dirty assert on the persistent pool. The ref-level -# commit diff below is blind to both. Rejected the same non-retryable way -# failure.md+dirty is above: a retryable rejection would engage the repair -# pass, which deletes handoff.md and may commit against the brake. +# commit diff below is blind to both. Non-retryable like failure.md+dirty +# above (a retryable rejection would engage the repair pass, which deletes +# handoff.md and may commit against the brake), but under its OWN outcome: +# outcome=failed would make the report step dress the rejection as a +# failed FIX ("could not produce a passing fix", or a stale-base retry +# promise) when no fix existed — the report step gives this shape its own +# honest headline. if [[ -s "${WORKDIR}/handoff.md" && -n "$(git status --porcelain)" ]]; then echo "❌ Agent wrote handoff.md after leaving a dirty workspace:" git status --short sed 's/::/;;/g' "${WORKDIR}/handoff.md" - echo "outcome=failed" >> "${GITHUB_OUTPUT}" + echo "outcome=dirty_handoff" >> "${GITHUB_OUTPUT}" exit 1 fi diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 5fb181a1e4f..f7e5da94ce0 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -6429,9 +6429,11 @@ jobs: # 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)" + # present, plus the dirty-handoff rejection (a brake + # violation the gate rejects under its own outcome) — 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)|wrote a handoff but left a dirty workspace' <<< "${WIN_HEADS}" || true)" # The brake's deliberate stop — its own bucket, not the # residual crash bucket: it tells the maintainer a human # decision is already waiting on this PR. @@ -6762,6 +6764,19 @@ jobs: # reason, and this stop is transient (the loop stays # engaged); the shepherd contract test pins the distinction. HEADLINE="🤖 AutoFix deferred this item to a human under instruction (round ${MARK_ROUND}/${MAX_ROUNDS}) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + elif [[ "${OUTCOME:-}" == 'dirty_handoff' ]]; then + # A brake VIOLATION, not a failed fix: the agent stopped + # under instruction but left a dirty workspace, so the + # gate rejected the round under its own outcome (never + # retryable — a repair pass would commit against the + # brake). No stale-base probe: there is no fix to + # re-attempt, and the probe's update-branch would merge + # main for a retry that must not happen. The watermark + # still advances (the feedback WAS read), so the item + # hands to a human without any automatic-retry promise. + # Wording guard: no "🤖 AutoFix stopped" prefix, same + # reason as the handoff branch above. + HEADLINE="🤖 AutoFix rejected this round — the agent wrote a handoff but left a dirty workspace, violating the brake's commit-nothing stop (round ${MARK_ROUND}/${MAX_ROUNDS}). Nothing was committed; the uncommitted work was discarded with the runner. The handoff note below names what the agent stopped on, and the run log lists the dirty files. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." else # The gate ran and rejected the agent's fix (a build/test # failure). Before handing to a human, check whether the PR is @@ -7169,10 +7184,11 @@ jobs: if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" fi - # 'fixed'/'noop'/'handoff' are the outcomes that published a round - # report — handoff posts its note + eval marker from the report - # step; anything else means the round stopped before publishing one. - if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' ]]; then + # 'fixed'/'noop'/'handoff'/'dirty_handoff' are the outcomes that + # published a round report — handoff and the dirty-handoff + # rejection post their note + eval marker from the report step; + # anything else means the round stopped before publishing one. + if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' || "${OUTCOME:-}" == 'dirty_handoff' ]]; then EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")" ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")" else diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 28143d951cd..bc7ca593d98 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -9933,11 +9933,15 @@ exit 1 const emitHandoff = reviewAddressReportStep.match( /HEADLINE="(🤖 AutoFix deferred this item to a human under instruction[^"]*)"/, )?.[1]; + const emitDirtyHandoff = reviewAddressReportStep.match( + /HEADLINE="(🤖 AutoFix rejected this round[^"]*)"/, + )?.[1]; expect(emitPush).toBeTruthy(); expect(emitNoop).toBeTruthy(); expect(emitTimeout).toBeTruthy(); expect(emitRejected).toBeTruthy(); expect(emitHandoff).toBeTruthy(); + expect(emitDirtyHandoff).toBeTruthy(); const needlePushed = digestBlock.match(/N_PUSHED=.*grep -c '([^']*)'/)?.[1]; const needleNoop = digestBlock.match(/N_NOOP=.*grep -c '([^']*)'/)?.[1]; const needleTimeout = digestBlock.match( @@ -9958,6 +9962,10 @@ exit 1 expect(emitNoop).toContain(needleNoop); expect(`🤖 AutoFix ${emitTimeout}`).toContain(needleTimeout); expect(emitRejected).toMatch(new RegExp(needleRejected)); + // The dirty-handoff rejection counts as a rejected round, not a + // residual crash — the needle is an alternation, so both emissions + // must match it. + expect(emitDirtyHandoff).toMatch(new RegExp(needleRejected)); expect(emitHandoff).toContain(needleHandoff); const HEADS = { push: '🤖 Addressed the latest review feedback (round 2/100). What changed…', @@ -9973,6 +9981,8 @@ exit 1 gate: '🤖 AutoFix hit a verification-gate error before reaching a verdict (attempt 3/100) — it will retry on the next scan.', handoff: "🤖 AutoFix deferred this item to a human under instruction (round 3/100) — the agent's handoff note below names the decision and the options.", + dirtyHandoff: + "🤖 AutoFix rejected this round — the agent wrote a handoff but left a dirty workspace, violating the brake's commit-nothing stop (round 6/100). Nothing was committed.", }; const K = '2026-07-01T00:00:00Z'; const evalC = (head, win, at, login = 'qwen-code-dev-bot') => ({ @@ -10060,13 +10070,14 @@ exit 1 evalC(HEADS.rejectedOld, K, '2026-07-09T00:00:00Z'), evalC(HEADS.rejectedNew, K, '2026-07-10T00:00:00Z'), evalC(HEADS.handoff, K, '2026-07-11T12:00:00Z'), + evalC(HEADS.dirtyHandoff, K, '2026-07-11T13: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), 1 deliberate stop(s) under instruction (deferred to a human), 0 other round(s)', + '6 pushed fix(es), 1 no-change review(s), 1 timeout(s), 3 rejected attempt(s), 1 deliberate stop(s) under instruction (deferred to a human), 0 other round(s)', ); expect(mixed.body).toContain('1 base update(s)'); expect(mixed.body).toContain('round 10/100, in the current window'); @@ -12991,6 +13002,13 @@ exit 1 status: 0, written: expect.stringContaining('outcome=handoff'), }); + // A dirty handoff is a REJECTION, not a deliberate verdict: the case + // fallthrough keeps the job red, and the outcome still reaches the + // report step so the shape gets its own honest headline. + expect(run({ FIRST_OUTCOME: 'dirty_handoff' })).toMatchObject({ + status: 1, + written: expect.stringContaining('outcome=dirty_handoff'), + }); expect( run({ FIRST_OUTCOME: 'failed', @@ -13264,7 +13282,7 @@ exit 1 // reads "finished", never "ended without publishing a report" above its // own report. expect(finalizeStatusCommentStep).toContain( - '[[ "${OUTCOME:-}" == \'fixed\' || "${OUTCOME:-}" == \'noop\' || "${OUTCOME:-}" == \'handoff\' ]]', + '[[ "${OUTCOME:-}" == \'fixed\' || "${OUTCOME:-}" == \'noop\' || "${OUTCOME:-}" == \'handoff\' || "${OUTCOME:-}" == \'dirty_handoff\' ]]', ); expect(finalizeStatusCommentStep).toContain( 'ended without publishing a report', @@ -15381,6 +15399,15 @@ exit 1 expect( runPostHandoff({ ...base, OUTCOME: 'handoff', JOB_STATUS: 'success' }), ).toBe('true'); + // A dirty-handoff rejection ends RED but must still post: it triggers + // via JOB_STATUS != success, so the honest-headline report lands. + expect( + runPostHandoff({ + ...base, + OUTCOME: 'dirty_handoff', + JOB_STATUS: 'failure', + }), + ).toBe('true'); expect(reviewAddressReportStep).toContain( "STALE: '${{ steps.prepare.outputs.stale }}'", ); @@ -15467,6 +15494,17 @@ exit 1 JOB_STATUS: 'success', }), ).toBe('2026-07-16T00:00:00Z|3'); + // 5. A dirty-handoff rejection (the brake violation): like the clean + // handoff the feedback WAS read, so the watermark advances and the + // round stays NON-terminal — no retry sentinel, no terminal stamp. + expect( + runMark({ + NEWEST: '2026-07-16T00:00:00Z', + DETAIL_FILE: '/tmp/handoff.md', + OUTCOME: 'dirty_handoff', + JOB_STATUS: 'failure', + }), + ).toBe('2026-07-16T00:00:00Z|3'); // The no-output-crash HEADLINE must only promise a retry when one will // actually happen: at the final attempt (MARK_ROUND == MAX_ROUNDS) the @@ -15502,6 +15540,64 @@ exit 1 // REASON regex reads that as a TERMINAL stop reason, and this stop is // transient. The shepherd contract test pins the same distinction. expect(handoffHead).not.toContain('AutoFix stopped'); + // A dirty-handoff rejection gets its own honest headline — never the + // failed-fix wording, never a retry promise, and (same wording guard) + // no "AutoFix stopped" prefix. + const dirtyHandoffHead = runHeadline({ + ROUND: '2', + DETAIL_FILE: '/tmp/handoff.md', + OUTCOME: 'dirty_handoff', + JOB_STATUS: 'failure', + }); + expect(dirtyHandoffHead).toContain('rejected this round'); + expect(dirtyHandoffHead).toContain( + 'wrote a handoff but left a dirty workspace', + ); + expect(dirtyHandoffHead).toContain('Nothing was committed'); + expect(dirtyHandoffHead).not.toContain('Could not produce a passing fix'); + expect(dirtyHandoffHead).not.toContain('will retry'); + expect(dirtyHandoffHead).not.toContain('AutoFix stopped'); + // The stale-base probe must NOT run for this shape: there is no fix to + // re-attempt. Replay the behind-main arm with a stubbed gh that flips + // a failed fix to the stale-base retry — the rejection headline must + // hold, and the probe's update-branch mutation must never happen. + const ghDir = mkdtempSync(join(tmpdir(), 'dirty-handoff-gh-')); + try { + const ghBin = join(ghDir, 'bin'); + mkdirSync(ghBin); + const ghCalls = join(ghDir, 'calls.log'); + writeFileSync( + join(ghBin, 'gh'), + `#!/usr/bin/env bash +echo "$*" >> ${JSON.stringify(ghCalls)} +for a in "$@"; do + case "$a" in + repos/*/compare/*) echo behind; exit 0 ;; + repos/*/commits/*) echo abc123def; exit 0 ;; + repos/*/pulls/*/update-branch) exit 0 ;; + esac +done +exit 0 +`, + ); + chmodSync(join(ghBin, 'gh'), 0o755); + const behindMain = runHeadline({ + ROUND: '2', + DETAIL_FILE: '/tmp/handoff.md', + OUTCOME: 'dirty_handoff', + JOB_STATUS: 'failure', + PATH: `${ghBin}:${process.env.PATH}`, + REPO: 'o/r', + PR: '1', + REPORT_HEAD: 'deadbeef', + }); + expect(behindMain).toBe(dirtyHandoffHead); + expect( + existsSync(ghCalls) ? readFileSync(ghCalls, 'utf8') : '', + ).not.toContain('update-branch'); + } finally { + rmSync(ghDir, { recursive: true, force: true }); + } const midCrash = runHeadline({ ROUND: '2' }); // MARK_ROUND=3 < 5 expect(midCrash).toContain('it will retry on the next scan'); expect(midCrash).not.toContain('Run log:'); @@ -17087,7 +17183,11 @@ describe('review verification gate: baseline A/B on deterministic rejection', () workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, }); expect(r.status).toBe(1); - expect(r.outputs).toContain('outcome=failed'); + // Its OWN outcome, not plain failed: the report step gives the brake + // violation an honest headline instead of dressing it as a failed + // fix or a stale-base retry promise. + expect(r.outputs).toContain('outcome=dirty_handoff'); + expect(r.outputs).not.toContain('outcome=failed'); expect(r.outputs).not.toContain('outcome=handoff'); // The rejection must stay non-retryable: retryable=true engages the // repair pass, which deletes handoff.md and may commit against the diff --git a/scripts/tests/qwen-fleet-shepherd-workflow.test.js b/scripts/tests/qwen-fleet-shepherd-workflow.test.js index c27f2fd008f..3aa1fb2d335 100644 --- a/scripts/tests/qwen-fleet-shepherd-workflow.test.js +++ b/scripts/tests/qwen-fleet-shepherd-workflow.test.js @@ -1567,13 +1567,14 @@ exit 1`; h.includes('a setup step failed') || h.includes('will retry on the next scan') || h.includes('Could not produce a passing fix for this feedback') || - h.includes('deferred this item to a human under instruction'), + h.includes('deferred this item to a human under instruction') || + h.includes('wrote a handoff but left a dirty workspace'), ); const unclassified = headlines.filter( (h) => !terminal.includes(h) && !transient.includes(h), ); expect(terminal.length).toBe(5); - expect(transient).toHaveLength(5); + expect(transient).toHaveLength(6); expect(unclassified).toEqual([]); const matches = (h) => execFileSync('jq', ['-rn', '--arg', 'b', h, `$b | test("${reasonRe}")`], { From ef859bd28a4d66474314b179c8f3704fcaa82be4 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 18 Aug 2026 10:12:48 +0000 Subject: [PATCH 06/11] fix(autofix): reject a handoff written beside a round commit, non-retryably (#9297) Co-authored-by: Qwen-Coder --- .../run-autofix-review-verification.sh | 21 +++++++++++-------- scripts/tests/qwen-autofix-workflow.test.js | 14 +++++++------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 8145bca1619..1384dada2aa 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -79,15 +79,18 @@ fi # brake-violating partial patch (otherwise reported as a clean stop and # discarded silently with the runner), and untracked leftovers would trip # the NEXT round's dirty assert on the persistent pool. The ref-level -# commit diff below is blind to both. Non-retryable like failure.md+dirty -# above (a retryable rejection would engage the repair pass, which deletes -# handoff.md and may commit against the brake), but under its OWN outcome: -# outcome=failed would make the report step dress the rejection as a -# failed FIX ("could not produce a passing fix", or a stale-base retry -# promise) when no fix existed — the report step gives this shape its own -# honest headline. -if [[ -s "${WORKDIR}/handoff.md" && -n "$(git status --porcelain)" ]]; then - echo "❌ Agent wrote handoff.md after leaving a dirty workspace:" +# commit diff below is blind to both. A round COMMIT beside the handoff is +# the same violation in committed shape: without this clause it falls +# through to the structural checks below, where a rejection is retryable by +# default — the repair pass would then delete handoff.md and may commit +# against the brake. Non-retryable like failure.md+dirty above, but under +# its OWN outcome: outcome=failed would make the report step dress the +# rejection as a failed FIX ("could not produce a passing fix", or a +# stale-base retry promise) when no fix existed — the report step gives +# this shape its own honest headline. +if [[ -s "${WORKDIR}/handoff.md" ]] && + { [[ -n "$(git status --porcelain)" ]] || [[ "${committed_rc:-0}" -eq 1 ]]; }; then + echo "❌ Agent wrote handoff.md beside uncommitted changes or a round commit:" git status --short sed 's/::/;;/g' "${WORKDIR}/handoff.md" echo "outcome=dirty_handoff" >> "${GITHUB_OUTPUT}" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 6f8d84dad68..5b22153af4c 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -18050,17 +18050,19 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(nothing.status).toBe(1); expect(nothing.outputs).toContain('outcome=failed'); - // Pins the guard's committed-side clause: with a round commit present, - // the handoff branch must NOT fire even though handoff.md exists — the - // round falls through to the address-summary gate instead. Deleting the - // `git diff --quiet` clause flips this to exit 0 / outcome=handoff, - // skipping every structural check on the committed diff. + // Pins the guard's committed-side clause: a round commit beside the + // handoff is the brake violation in committed shape — classified as a + // non-retryable dirty handoff BEFORE the structural checks. Letting it + // fall through there makes a rejection retryable by default, and the + // repair pass would delete handoff.md and may commit against the brake. + // Deleting the committed_rc clause restores that fall-through. const committedWithHandoff = runGate({ agentCommit: true, workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, }); expect(committedWithHandoff.status).toBe(1); - expect(committedWithHandoff.outputs).toContain('outcome=failed'); + expect(committedWithHandoff.outputs).toContain('outcome=dirty_handoff'); + expect(committedWithHandoff.outputs).not.toContain('retryable=true'); expect(committedWithHandoff.outputs).not.toContain('outcome=handoff'); // The handoff note is agent-written and lands in the privileged job's From d8d4845ff0c1eb9310fadb5d7ec36ec35ef42c59 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 18 Aug 2026 18:24:25 +0800 Subject: [PATCH 07/11] fix(ci): classify the committed handoff shape as its own non-retryable outcome R7-1 on this PR: a round that HAS a commit beside handoff.md skipped both brake-violation guards (clean tree misses the dirty guard; committed ref misses the no-commit branch) and fell through to the structural checks, where reject_fix defaults to retryable and the repair pass deletes handoff.md and may commit again against the brake's stop. Classify it before the structural checks under its own outcome committed_handoff, sibling of dirty_handoff: non-retryable, its own honest report headline (reusing dirty_handoff's wording would claim nothing was committed when a commit exists), listed among the report-publishing outcomes in the status classifier, and never routed through finalize's pass list. Pins updated in the same pass: the committedWithHandoff gate case now expects committed_handoff with no retryable, the shepherd contract test classifies the new headline as transient (loop stays engaged), the status-classifier pin names all five outcomes, and the handoff-contract gate test gets an explicit subprocess budget (eight fixture arms outgrew the 5s default). --- .../run-autofix-review-verification.sh | 16 ++++++++++ .github/workflows/qwen-autofix.yml | 23 +++++++++++--- scripts/tests/qwen-autofix-workflow.test.js | 31 +++++++++++++------ .../qwen-fleet-shepherd-workflow.test.js | 5 +-- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 8145bca1619..42211e82d1a 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -94,6 +94,22 @@ if [[ -s "${WORKDIR}/handoff.md" && -n "$(git status --porcelain)" ]]; then exit 1 fi +# The committed sibling of the brake violation above: the round HAS a commit +# beside handoff.md. Both guards it would otherwise fall through miss it — +# the dirty check sees a clean tree, and the no-commit handoff branch below +# requires an unchanged ref — so it would reach the structural checks, where +# reject_fix defaults to retryable and the repair pass deletes +# handoff.md and may commit AGAIN against the brake's stop. Non-retryable +# under its OWN outcome (the report step gives this shape its own honest +# headline), same reasoning as the dirty guard. +if [[ -s "${WORKDIR}/handoff.md" && "${committed_rc:-0}" -eq 1 ]]; then + echo "❌ Agent wrote handoff.md but the round HAS a commit — a brake violation:" + git log --oneline "origin/${BRANCH}..${BRANCH}" + sed 's/::/;;/g' "${WORKDIR}/handoff.md" + echo "outcome=committed_handoff" >> "${GITHUB_OUTPUT}" + exit 1 +fi + # No-commit brake handoff, classified BEFORE the structural checks below: # those judge the PR's OWN diff (core rebuild, schema freshness, contracts) # and reject_fix on failure, and the growth brake fires on exactly the red diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 3e9aa94caa7..926af2dfd6c 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -7107,6 +7107,18 @@ jobs: # Wording guard: no "🤖 AutoFix stopped" prefix, same # reason as the handoff branch above. HEADLINE="🤖 AutoFix rejected this round — the agent wrote a handoff but left a dirty workspace, violating the brake's commit-nothing stop (round ${MARK_ROUND}/${MAX_ROUNDS}). Nothing was committed; the uncommitted work was discarded with the runner. The handoff note below names what the agent stopped on, and the run log lists the dirty files. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + elif [[ "${OUTCOME:-}" == 'committed_handoff' ]]; then + # The committed sibling of the dirty-handoff violation: + # the round HAS a commit beside handoff.md. The gate + # rejected it non-retryably under its own outcome, so the + # repair pass never engages and the handoff note survives + # to be posted. The runner-side commit was NOT pushed — + # only 'Push and report' (fixed/noop) publishes — so it is + # discarded with the runner, the same committed_rc the + # failure.md "commit discarded" wording keys on. + # Wording guard: no "🤖 AutoFix stopped" prefix, same + # reason as the handoff branch above. + HEADLINE="🤖 AutoFix rejected this round — the agent wrote a handoff but the round HAS a commit, violating the brake's commit-nothing stop (round ${MARK_ROUND}/${MAX_ROUNDS}). The commit was NOT pushed and is discarded with the runner; the run log lists it. The handoff note below names what the agent stopped on. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." else # The gate ran and rejected the agent's fix (a build/test # failure). Before handing to a human, check whether the PR is @@ -7514,11 +7526,12 @@ jobs: if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" fi - # 'fixed'/'noop'/'handoff'/'dirty_handoff' are the outcomes that - # published a round report — handoff and the dirty-handoff - # rejection post their note + eval marker from the report step; - # anything else means the round stopped before publishing one. - if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' || "${OUTCOME:-}" == 'dirty_handoff' ]]; then + # 'fixed'/'noop'/'handoff'/'dirty_handoff'/'committed_handoff' are + # the outcomes that published a round report — handoff and the two + # brake-violation rejections post their note + eval marker from the + # report step; anything else means the round stopped before + # publishing one. + if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' || "${OUTCOME:-}" == 'dirty_handoff' || "${OUTCOME:-}" == 'committed_handoff' ]]; then EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")" ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")" else diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 6f8d84dad68..0a01beb915d 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -14162,11 +14162,12 @@ exit 1 expect(statusStep).toContain('^[0-9]+$'); } // Tells a round that published a report from one that died before it. - // handoff publishes one too (the handoff note + eval marker), so it - // reads "finished", never "ended without publishing a report" above its - // own report. + // handoff publishes one too (the handoff note + eval marker), and so do + // the two brake-violation rejections (their note + marker post from the + // report step), so all five read "finished", never "ended without + // publishing a report" above their own report. expect(finalizeStatusCommentStep).toContain( - '[[ "${OUTCOME:-}" == \'fixed\' || "${OUTCOME:-}" == \'noop\' || "${OUTCOME:-}" == \'handoff\' || "${OUTCOME:-}" == \'dirty_handoff\' ]]', + '[[ "${OUTCOME:-}" == \'fixed\' || "${OUTCOME:-}" == \'noop\' || "${OUTCOME:-}" == \'handoff\' || "${OUTCOME:-}" == \'dirty_handoff\' || "${OUTCOME:-}" == \'committed_handoff\' ]]', ); expect(finalizeStatusCommentStep).toContain( 'ended without publishing a report', @@ -18051,17 +18052,23 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(nothing.outputs).toContain('outcome=failed'); // Pins the guard's committed-side clause: with a round commit present, - // the handoff branch must NOT fire even though handoff.md exists — the - // round falls through to the address-summary gate instead. Deleting the - // `git diff --quiet` clause flips this to exit 0 / outcome=handoff, - // skipping every structural check on the committed diff. + // the no-commit handoff branch must NOT fire — the round is a brake + // VIOLATION (the brake says commit nothing), classified + // outcome=committed_handoff before the structural checks. Falling + // through to them would reject retryable=true, and the repair pass + // deletes handoff.md and may commit again against the brake. Deleting + // the committed-side guard flips this to outcome=handoff, skipping every + // structural check on the committed diff; routing it through the checks + // instead flips it to outcome=failed with retryable left unset ONLY if + // the fixture trips nothing — the shape this pin exists to prevent. const committedWithHandoff = runGate({ agentCommit: true, workdirFiles: { 'handoff.md': 'needs a maintainer decision\n' }, }); expect(committedWithHandoff.status).toBe(1); - expect(committedWithHandoff.outputs).toContain('outcome=failed'); + expect(committedWithHandoff.outputs).toContain('outcome=committed_handoff'); expect(committedWithHandoff.outputs).not.toContain('outcome=handoff'); + expect(committedWithHandoff.outputs).not.toContain('retryable'); // The handoff note is agent-written and lands in the privileged job's // step log: a line-start `::` would parse as a workflow command, so the @@ -18074,7 +18081,11 @@ describe('review verification gate: baseline A/B on deterministic rejection', () expect(neutralized.outputs).toContain('outcome=handoff'); expect(neutralized.stdout).toContain(';;error;;forged'); expect(neutralized.stdout).not.toContain('::error::forged'); - }); + // Eight runGate arms, each a fixture repo plus a full gate-script + // replay under bash — this outgrows the 5s default on slow runners + // (it timed out at ~6.4s on the PR head); the suite's convention is + // an explicit per-test budget for tests that spawn subprocesses. + }, 30000); it('rejects a handoff written over a dirty workspace, non-retryably', () => { // A handoff claims the round deliberately changed NOTHING; dirt beside diff --git a/scripts/tests/qwen-fleet-shepherd-workflow.test.js b/scripts/tests/qwen-fleet-shepherd-workflow.test.js index 3aa1fb2d335..103cf4515fd 100644 --- a/scripts/tests/qwen-fleet-shepherd-workflow.test.js +++ b/scripts/tests/qwen-fleet-shepherd-workflow.test.js @@ -1568,13 +1568,14 @@ exit 1`; h.includes('will retry on the next scan') || h.includes('Could not produce a passing fix for this feedback') || h.includes('deferred this item to a human under instruction') || - h.includes('wrote a handoff but left a dirty workspace'), + h.includes('wrote a handoff but left a dirty workspace') || + h.includes('wrote a handoff but the round HAS a commit'), ); const unclassified = headlines.filter( (h) => !terminal.includes(h) && !transient.includes(h), ); expect(terminal.length).toBe(5); - expect(transient).toHaveLength(6); + expect(transient).toHaveLength(7); expect(unclassified).toEqual([]); const matches = (h) => execFileSync('jq', ['-rn', '--arg', 'b', h, `$b | test("${reasonRe}")`], { From 9901ba87b4e4f6cbd9c7ae868fca4f10a435c83f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 18 Aug 2026 16:30:19 +0000 Subject: [PATCH 08/11] fix(ci): count committed-handoff rounds in the milestone census rejected bucket (#9297) --- .github/workflows/qwen-autofix.yml | 8 ++++---- scripts/tests/qwen-autofix-workflow.test.js | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 926af2dfd6c..3f71e39e227 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -6759,11 +6759,11 @@ jobs: # 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, plus the dirty-handoff rejection (a brake - # violation the gate rejects under its own outcome) — the - # census must not silently zero when the headline is + # present, plus both handoff brake violations, dirty and + # committed (the gate rejects each under its own outcome) + # — 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)|wrote a handoff but left a dirty workspace' <<< "${WIN_HEADS}" || true)" + N_REJECTED="$(grep -cE 'Could not (address the latest feedback|produce a passing fix)|wrote a handoff but (left a dirty workspace|the round HAS a commit)' <<< "${WIN_HEADS}" || true)" # The brake's deliberate stop — its own bucket, not the # residual crash bucket: it tells the maintainer a human # decision is already waiting on this PR. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index bb9b1966ff6..846329d4202 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -10793,7 +10793,10 @@ exit 1 /HEADLINE="(🤖 AutoFix deferred this item to a human under instruction[^"]*)"/, )?.[1]; const emitDirtyHandoff = reviewAddressReportStep.match( - /HEADLINE="(🤖 AutoFix rejected this round[^"]*)"/, + /HEADLINE="(🤖 AutoFix rejected this round — the agent wrote a handoff but left a dirty workspace[^"]*)"/, + )?.[1]; + const emitCommittedHandoff = reviewAddressReportStep.match( + /HEADLINE="(🤖 AutoFix rejected this round — the agent wrote a handoff but the round HAS a commit[^"]*)"/, )?.[1]; expect(emitPush).toBeTruthy(); expect(emitNoop).toBeTruthy(); @@ -10801,6 +10804,7 @@ exit 1 expect(emitRejected).toBeTruthy(); expect(emitHandoff).toBeTruthy(); expect(emitDirtyHandoff).toBeTruthy(); + expect(emitCommittedHandoff).toBeTruthy(); const needlePushed = digestBlock.match(/N_PUSHED=.*grep -c '([^']*)'/)?.[1]; const needleNoop = digestBlock.match(/N_NOOP=.*grep -c '([^']*)'/)?.[1]; const needleTimeout = digestBlock.match( @@ -10821,10 +10825,11 @@ exit 1 expect(emitNoop).toContain(needleNoop); expect(`🤖 AutoFix ${emitTimeout}`).toContain(needleTimeout); expect(emitRejected).toMatch(new RegExp(needleRejected)); - // The dirty-handoff rejection counts as a rejected round, not a - // residual crash — the needle is an alternation, so both emissions - // must match it. + // Both handoff brake violations count as rejected rounds, not + // residual crashes — the needle is an alternation, so every + // emission must match it. expect(emitDirtyHandoff).toMatch(new RegExp(needleRejected)); + expect(emitCommittedHandoff).toMatch(new RegExp(needleRejected)); expect(emitHandoff).toContain(needleHandoff); const HEADS = { push: '🤖 Addressed the latest review feedback (round 2/100). What changed…', @@ -10842,6 +10847,8 @@ exit 1 "🤖 AutoFix deferred this item to a human under instruction (round 3/100) — the agent's handoff note below names the decision and the options.", dirtyHandoff: "🤖 AutoFix rejected this round — the agent wrote a handoff but left a dirty workspace, violating the brake's commit-nothing stop (round 6/100). Nothing was committed.", + committedHandoff: + "🤖 AutoFix rejected this round — the agent wrote a handoff but the round HAS a commit, violating the brake's commit-nothing stop (round 7/100). The commit was NOT pushed.", }; const K = '2026-07-01T00:00:00Z'; const evalC = (head, win, at, login = 'qwen-code-dev-bot') => ({ @@ -10919,7 +10926,7 @@ exit 1 // 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. + // every rejection wording counts, 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'), @@ -10932,13 +10939,14 @@ exit 1 evalC(HEADS.rejectedNew, K, '2026-07-10T00:00:00Z'), evalC(HEADS.handoff, K, '2026-07-11T12:00:00Z'), evalC(HEADS.dirtyHandoff, K, '2026-07-11T13:00:00Z'), + evalC(HEADS.committedHandoff, K, '2026-07-11T14: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), 3 rejected attempt(s), 1 deliberate stop(s) under instruction (deferred to a human), 0 other round(s)', + '6 pushed fix(es), 1 no-change review(s), 1 timeout(s), 4 rejected attempt(s), 1 deliberate stop(s) under instruction (deferred to a human), 0 other round(s)', ); expect(mixed.body).toContain('1 base update(s)'); expect(mixed.body).toContain('round 10/100, in the current window'); From 8972874555177c891726bd6f50ac6c8b9c9e45a7 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 18 Aug 2026 21:33:00 +0000 Subject: [PATCH 09/11] fix(autofix): publish brake violations green and preserve handoffs across crashes (#9297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Critical review findings on the handoff output contract. Brake-violation rounds (dirty_handoff / committed_handoff) ended with a red review-address check: the eval marker stamps ts=NEWEST, strictly before the check completes, and the scan counts failed checks completed after the watermark — including this workflow's own review-address checks — as new feedback. The next scan re-selected the PR and burned a full agent round on the item the posted headline promised not to retry, once per violation. Admit both outcomes to the green finalize arm the way the clean handoff already is (the diff's own comment names this self-feeding loop as the reason handoff went green), and key the report step's routing and POST_HANDOFF trigger on the outcomes themselves so the green rounds still publish their honest headline, handoff note, and eval marker instead of going silent. A crash, budget kill, or loop guard after the agent wrote handoff.md synthesized a failure.md that shadowed the note: the gate reads failure.md first (outcome=failed), the report preferred it, and the timeout sentinel re-handed the item the brake stopped. Preserve the agent-written handoff in the crash branch (exit 0, mirroring the agent-written-failure.md arm), and never let writeHandoff overwrite a non-empty agent verdict. Both findings reproduced against this commit's verbatim code before fixing: the case/jq replay showed the violation check red and counted as new feedback, and a stub run showed the synthesized failure.md shadowing the handoff. New behavioral tests fail pre-fix and pass post-fix. --- .github/workflows/qwen-autofix.yml | 31 +-- .qwen/skills/autofix/scripts/run-agent.mjs | 23 ++- scripts/tests/qwen-autofix-workflow.test.js | 203 +++++++++++++++++++- 3 files changed, 240 insertions(+), 17 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 3f71e39e227..9663867dad7 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -6090,14 +6090,19 @@ jobs: echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" fi case "${OUTCOME}" in - # handoff is a deliberate verdict, not a failure: the agent - # stopped under instruction (the growth-brake BLOCKED stop) and - # deferred the item to a human. Failing the job here would leave - # a red review-address check, which the next scan counts as NEW - # feedback — turning one deliberate stop into a self-feeding - # loop. The 'Report dry-run / failure' step still posts the - # handoff comment and the eval marker for this outcome. - fixed|noop|handoff) ;; + # handoff and the two brake-violation rejections are + # deliberate, PUBLISHED verdicts, not failures: the agent + # stopped under instruction (the growth-brake BLOCKED stop) + # and the 'Report dry-run / failure' step posts the honest + # headline, the handoff note, and the eval marker for all + # three. Failing the job here would leave a red + # review-address check that completes AFTER the marker's + # ts=NEWEST — the next scan's N_FAILED_CHECKS includes this + # workflow's own review-address checks, so it would count + # the round's own rejection as NEW feedback and re-dispatch + # the very item the headline promises not to retry, turning + # one deliberate stop into a self-feeding loop. + fixed|noop|handoff|dirty_handoff|committed_handoff) ;; *) exit 1 ;; esac @@ -6815,7 +6820,7 @@ jobs: - name: 'Report dry-run / failure' if: |- - ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled() || steps.final_verify.outputs.outcome == 'handoff') }} + ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled() || steps.final_verify.outputs.outcome == 'handoff' || steps.final_verify.outputs.outcome == 'dirty_handoff' || steps.final_verify.outputs.outcome == 'committed_handoff') }} env: OUTCOME: '${{ steps.final_verify.outputs.outcome }}' COMMITTED: '${{ steps.final_verify.outputs.committed }}' @@ -6907,8 +6912,12 @@ jobs: # handoff rounds end with a SUCCESS job status (a deliberate # verdict), so they must trigger on the outcome itself — without # this clause nothing would post and the loop would go silent on - # exactly the rounds that most need a visible human handoff. - if [[ "${OUTCOME:-unknown}" == "failed" || "${OUTCOME:-unknown}" == "handoff" || "${JOB_STATUS:-}" != "success" ]]; then + # exactly the rounds that most need a visible human handoff. The + # two brake-violation rejections are green, published verdicts + # for the same reason (finalize passes them so their own check + # cannot re-select the PR), so they key on the outcome the same + # way. + if [[ "${OUTCOME:-unknown}" == "failed" || "${OUTCOME:-unknown}" == "handoff" || "${OUTCOME:-unknown}" == "dirty_handoff" || "${OUTCOME:-unknown}" == "committed_handoff" || "${JOB_STATUS:-}" != "success" ]]; then POST_HANDOFF=true fi fi diff --git a/.qwen/skills/autofix/scripts/run-agent.mjs b/.qwen/skills/autofix/scripts/run-agent.mjs index 48dde3dabf9..7d2eb53d95b 100755 --- a/.qwen/skills/autofix/scripts/run-agent.mjs +++ b/.qwen/skills/autofix/scripts/run-agent.mjs @@ -170,7 +170,12 @@ function recoverableApiError(output) { function writeHandoff(workdir, message) { mkdirSync(workdir, { recursive: true }); - writeFileSync(file(workdir, 'handoff.md'), `${message}\n`); + const handoffPath = file(workdir, 'handoff.md'); + // A non-empty handoff.md is an agent-written verdict — the same + // convention missing() and the gate's -s checks apply — and a verdict + // must not be reclassified by a synthesized note. + if (existsSync(handoffPath) && statSync(handoffPath).size > 0) return; + writeFileSync(handoffPath, `${message}\n`); } function isLoopGuardOutput(output) { @@ -525,6 +530,22 @@ if ( ? 'recoverable API error without an agent verdict' : `status ${String(result.status)}`; if (!existsSync(file(options.workdir, 'failure.md'))) { + if (hasHandoffVerdict) { + // A handoff is a verdict here too: the agent stopped under + // instruction and wrote its decision, then qwen died (crash, + // budget kill, loop guard). Synthesizing a failure.md would + // shadow the note — the gate reads failure.md first and would + // report a failed fix, and the timeout/api-error sentinels would + // re-hand the item the brake stopped on the next scan. Mirror the + // sibling arm that preserves an agent-written failure.md across + // the same crash, and exit 0 like the status-0 handoff path so + // the check color matches the outcome the gate reports from the + // preserved note. + console.error( + `Qwen failed during ${options.mode}: ${detail}; preserving agent-written handoff.md.`, + ); + process.exit(0); + } if (result.loopDetected) { writeFailure( options.workdir, diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 846329d4202..51356d63cb5 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -13704,10 +13704,17 @@ exit 1 ); expect(reviewAddressReportStep).toContain('failure() || cancelled()'); // The handoff outcome also routes here: a green, deliberate stop must - // still post its report + eval marker, never go silent. + // still post its report + eval marker, never go silent. The two + // brake-violation rejections are green, published verdicts the same way. expect(reviewAddressReportStep).toContain( "steps.final_verify.outputs.outcome == 'handoff'", ); + expect(reviewAddressReportStep).toContain( + "steps.final_verify.outputs.outcome == 'dirty_handoff'", + ); + expect(reviewAddressReportStep).toContain( + "steps.final_verify.outputs.outcome == 'committed_handoff'", + ); expect(reviewAddressReportStep).not.toContain( "steps.verify.outputs.outcome == 'failed'", ); @@ -13894,13 +13901,21 @@ exit 1 status: 0, written: expect.stringContaining('outcome=handoff'), }); - // A dirty handoff is a REJECTION, not a deliberate verdict: the case - // fallthrough keeps the job red, and the outcome still reaches the - // report step so the shape gets its own honest headline. + // The two brake-violation rejections are published verdicts too: the + // report step posts their honest headline, the handoff note, and the + // eval marker. The job must end GREEN like the clean handoff — a red + // review-address check completes AFTER the marker's ts, so the next + // scan counts the round's own rejection as new feedback and + // re-dispatches the item the headline promised not to retry (the + // self-feeding loop pinned by the scan-count test below). expect(run({ FIRST_OUTCOME: 'dirty_handoff' })).toMatchObject({ - status: 1, + status: 0, written: expect.stringContaining('outcome=dirty_handoff'), }); + expect(run({ FIRST_OUTCOME: 'committed_handoff' })).toMatchObject({ + status: 0, + written: expect.stringContaining('outcome=committed_handoff'), + }); expect( run({ FIRST_OUTCOME: 'failed', @@ -13947,6 +13962,61 @@ exit 1 ).toMatchObject({ status: 1 }); }); + it("keeps a verdict round's own check out of the next scan's failed-check count", () => { + // A brake-verdict round (clean handoff or either violation rejection) + // publishes its eval marker with ts=NEWEST — strictly BEFORE its own + // check completes. If that check is red, the next scan's + // N_FAILED_CHECKS (completedAt > watermark, review-address checks + // included) counts the round's OWN rejection as new feedback and + // re-dispatches the item the headline promised not to retry, once per + // violation. Replay the real finalize case to derive the check color, + // then the real scan count over it: every published verdict must + // leave the PR unselected by its own check. + const caseBlock = finalizeVerificationStep.match( + /case "\$\{OUTCOME\}" in\n[\s\S]*?\n\s*esac/, + )?.[0]; + expect(caseBlock).toBeTruthy(); + const checkColor = (outcome) => { + const res = spawnSync('bash', [ + '-c', + `set -u\nOUTCOME='${outcome}'\n${caseBlock}`, + ]); + return res.status === 0 ? 'SUCCESS' : 'FAILURE'; + }; + const countBlock = reviewScanJob.match( + /N_FAILED_CHECKS="\$\(jq --arg wm "\$\{EFF_WM\}" '[\s\S]*?<<< "\$\{CHECKS_JSON\}"\)"/, + )?.[0]; + expect(countBlock).toBeTruthy(); + const MARKER_TS = '2026-08-18T10:00:00Z'; // the eval marker's ts=NEWEST + const CHECK_TS = '2026-08-18T11:00:00Z'; // the job completes later + const selected = (outcome) => + execFileSync( + 'bash', + ['-c', `${countBlock}\nprintf '%s' "$N_FAILED_CHECKS"`], + { + env: { + ...process.env, + EFF_WM: MARKER_TS, + CHECKS_JSON: JSON.stringify([ + { + name: 'review-address (1, branch)', + workflowName: 'Qwen Autofix', + conclusion: checkColor(outcome), + completedAt: CHECK_TS, + }, + ]), + }, + encoding: 'utf8', + }, + ); + expect(selected('handoff')).toBe('0'); + expect(selected('dirty_handoff')).toBe('0'); + expect(selected('committed_handoff')).toBe('0'); + // A genuine failure stays red and therefore selected: the loop is + // closed via the green arm, not by blinding the scan to failures. + expect(selected('failed')).toBe('1'); + }); + it('posts a human-handoff marker when review addressing reaches a terminal handoff', () => { expect(reviewAddressReportStep).toContain( "GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'", @@ -16320,6 +16390,25 @@ exit 1 JOB_STATUS: 'failure', }), ).toBe('true'); + // Both brake-violation rejections are GREEN, published verdicts (the + // finalize case passes them so their own red check cannot re-select + // the PR), so they must key on the outcome exactly like the clean + // handoff — without these clauses nothing would post and the loop + // would go silent on exactly the rounds that need a human. + expect( + runPostHandoff({ + ...base, + OUTCOME: 'dirty_handoff', + JOB_STATUS: 'success', + }), + ).toBe('true'); + expect( + runPostHandoff({ + ...base, + OUTCOME: 'committed_handoff', + JOB_STATUS: 'success', + }), + ).toBe('true'); expect(reviewAddressReportStep).toContain( "STALE: '${{ steps.prepare.outputs.stale }}'", ); @@ -17452,6 +17541,110 @@ exit 0 }); }); + it('preserves an agent-written handoff when qwen dies after it', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + "writeFileSync(`${workdir}/handoff.md`, 'needs a maintainer decision\\n');", + 'process.exit(1);', + ]); + + // The handoff is a verdict: a crash after it must not shadow the + // note with a synthesized failure.md (the gate reads failure.md + // first and would report a failed fix), and the runner exits 0 so + // the check color matches the handoff outcome the gate reports. + const result = runAddressReview(dir, stub); + expect(result.status).toBe(0); + expect(result.stderr).toContain('preserving agent-written handoff.md'); + expect(existsSync(join(dir, 'failure.md'))).toBe(false); + expect(existsSync(join(dir, 'agent-timeout'))).toBe(false); + expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain( + 'needs a maintainer decision', + ); + }); + }); + + it('preserves an agent-written handoff when the budget kills qwen after it', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + "writeFileSync(`${workdir}/handoff.md`, 'needs a maintainer decision\\n');", + 'setTimeout(() => {}, 30_000);', + ]); + + // The timeout shape of the same window: the kill path must not + // write failure.md or the retry sentinel — the sentinel ts would + // re-hand the item the brake stopped on the next scan. + const previousTimeout = process.env.QWEN_TIMEOUT_MS; + const previousIdle = process.env.QWEN_IDLE_TIMEOUT_MS; + process.env.QWEN_TIMEOUT_MS = '600'; + delete process.env.QWEN_IDLE_TIMEOUT_MS; + try { + const result = runAddressReview(dir, stub); + expect(result.status).toBe(0); + expect(existsSync(join(dir, 'failure.md'))).toBe(false); + expect(existsSync(join(dir, 'agent-timeout'))).toBe(false); + expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain( + 'needs a maintainer decision', + ); + } finally { + if (previousTimeout === undefined) delete process.env.QWEN_TIMEOUT_MS; + else process.env.QWEN_TIMEOUT_MS = previousTimeout; + if (previousIdle === undefined) delete process.env.QWEN_IDLE_TIMEOUT_MS; + else process.env.QWEN_IDLE_TIMEOUT_MS = previousIdle; + } + }); + }); + + it('preserves an agent-written handoff across the loop guard', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + `process.stdout.write(${JSON.stringify( + qwenResultLine({ + errorMessage: 'turn_tool_call_cap: too many tool calls', + isError: true, + }), + )});`, + "writeFileSync(`${workdir}/handoff.md`, 'needs a maintainer decision\\n');", + 'process.exit(1);', + ]); + + // The loop guard is a real defect, but a handoff written before it + // fired is still the round's verdict — it must not be reclassified + // as a failed fix or overwritten with the generic note. + const result = runAddressReview(dir, stub); + expect(result.status).toBe(0); + expect(existsSync(join(dir, 'failure.md'))).toBe(false); + expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain( + 'needs a maintainer decision', + ); + }); + }); + + it('never overwrites an agent-written handoff with a synthesized note', () => { + withRunnerDir((dir) => { + writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); + const stub = writeWorkdirStub(dir, [ + "writeFileSync(`${workdir}/failure.md`, 'agent failure detail\\n');", + "writeFileSync(`${workdir}/handoff.md`, 'agent handoff note\\n');", + 'process.exit(1);', + ]); + + // failure.md keeps the failed classification, but the agent's + // handoff note must survive verbatim — a verdict is never + // overwritten by a synthesized one. + const result = runAddressReview(dir, stub); + expect(result.status).not.toBe(0); + expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain( + 'agent failure detail', + ); + expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toBe( + 'agent handoff note\n', + ); + }); + }); + it('rejects mutually exclusive address-review output files', () => { withRunnerDir((dir) => { writeFileSync(join(dir, 'feedback.md'), 'feedback\n'); From f1f2d17f766f318af888001aa2715dc8348bb81d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 19 Aug 2026 14:25:28 +0800 Subject: [PATCH 10/11] test(ci): give four subprocess-heavy replays explicit budgets The milestone digest, stale-duplicate revalidation, deny-by-default footprint, and recoverable-API-render tests spawn multiple bash replays of the real workflow/gate scripts each; the files those replays parse grew with this PR's handoff chain, and all four outgrew the 5s default (each verified to pass with an explicit 30s budget, matching the suite's convention for subprocess-heavy tests). --- scripts/tests/qwen-autofix-workflow.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 51356d63cb5..9a829f65a5b 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -1957,7 +1957,7 @@ describe('qwen-autofix workflow', () => { head: H, }).stale, ).toBe(false); - }); + }, 30000); it('behaviorally replays the eligibility recheck across lifecycle and label states', () => { // Extract the recheck VERBATIM (drift fails the test) and run it with a @@ -11068,7 +11068,7 @@ exit 1 /then\n\s+echo "📊 milestone digest posted/, ); expect(pushAndReportStep).toContain('milestone digest failed to post'); - }); + }, 30000); it('salvages a race-lost push by merging the moved head instead of discarding the run', () => { // A one-shot push dies `fetch first` whenever anything pushes to the PR @@ -12094,7 +12094,7 @@ exit 1 const fuzz = run(crossWorkspace, { enforce: 'terminate' }); expect(fuzz.out).toContain('SURVIVED'); expect(fuzz.advisory).toContain('outside the PR footprint'); - }); + }, 30000); it('upserts deferred findings into a per-PR issue that survives the merge', () => { // Wiring: the upsert runs after both shared resolve/reply call sites @@ -17189,7 +17189,7 @@ exit 0 expect(runAddressReview(dir, stub).status).not.toBe(0); expect(existsSync(join(dir, 'agent-api-error'))).toBe(false); }); - }); + }, 30000); it('classifies permanent API failures terminal and records the cause class', () => { // A permanent 400 whose text happens to carry a 3-digit number in 500-599 From cc3a05d0263fdc585bca85415a1d707f180909f5 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 20 Aug 2026 00:19:28 +0000 Subject: [PATCH 11/11] fix(ci): mirror the handoff outcome consumers into the recovery clone (#9297) --- .github/workflows/qwen-autofix-recovery.yml | 97 ++++++++++++++++++--- scripts/tests/qwen-autofix-workflow.test.js | 18 ++++ 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/.github/workflows/qwen-autofix-recovery.yml b/.github/workflows/qwen-autofix-recovery.yml index 2ddd59837dc..c9e1c500c22 100644 --- a/.github/workflows/qwen-autofix-recovery.yml +++ b/.github/workflows/qwen-autofix-recovery.yml @@ -6145,7 +6145,19 @@ jobs: echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}" fi case "${OUTCOME}" in - fixed|noop) ;; + # handoff and the two brake-violation rejections are + # deliberate, PUBLISHED verdicts, not failures: the agent + # stopped under instruction (the growth-brake BLOCKED stop) + # and the 'Report dry-run / failure' step posts the honest + # headline, the handoff note, and the eval marker for all + # three. Failing the job here would leave a red + # review-address check that completes AFTER the marker's + # ts=NEWEST — the next scan's N_FAILED_CHECKS includes this + # workflow's own review-address checks, so it would count + # the round's own rejection as NEW feedback and re-dispatch + # the very item the headline promises not to retry, turning + # one deliberate stop into a self-feeding loop. + fixed|noop|handoff|dirty_handoff|committed_handoff) ;; *) exit 1 ;; esac @@ -6874,15 +6886,21 @@ jobs: # 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)" + # present, plus both handoff brake violations, dirty and + # committed (the gate rejects each under its own outcome) + # — 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)|wrote a handoff but (left a dirty workspace|the round HAS a commit)' <<< "${WIN_HEADS}" || true)" + # The brake's deliberate stop — its own bucket, not the + # residual crash bucket: it tells the maintainer a human + # decision is already waiting on this PR. + N_HANDOFF="$(grep -c 'deferred this item to a human under instruction' <<< "${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=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED - N_HANDOFF )) (( 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 @@ -6899,7 +6917,7 @@ jobs: 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 + 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 deliberate stop(s) under instruction (deferred to a human), %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 次、按指示有意停止(移交人工)%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_HANDOFF}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_HANDOFF}" "${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" @@ -6924,7 +6942,7 @@ jobs: - name: 'Report dry-run / failure' if: |- - ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }} + ${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled() || steps.final_verify.outputs.outcome == 'handoff' || steps.final_verify.outputs.outcome == 'dirty_handoff' || steps.final_verify.outputs.outcome == 'committed_handoff') }} env: OUTCOME: '${{ steps.final_verify.outputs.outcome }}' COMMITTED: '${{ steps.final_verify.outputs.committed }}' @@ -7013,7 +7031,15 @@ jobs: # consumes a round. POST_HANDOFF=false if [[ "${DRY_RUN}" != "true" && "${STALE:-}" != "true" && -n "${GITHUB_TOKEN:-}" && "${OUTCOME:-unknown}" != "fixed" && "${OUTCOME:-unknown}" != "noop" ]]; then - if [[ "${OUTCOME:-unknown}" == "failed" || "${JOB_STATUS:-}" != "success" ]]; then + # handoff rounds end with a SUCCESS job status (a deliberate + # verdict), so they must trigger on the outcome itself — without + # this clause nothing would post and the loop would go silent on + # exactly the rounds that most need a visible human handoff. The + # two brake-violation rejections are green, published verdicts + # for the same reason (finalize passes them so their own check + # cannot re-select the PR), so they key on the outcome the same + # way. + if [[ "${OUTCOME:-unknown}" == "failed" || "${OUTCOME:-unknown}" == "handoff" || "${OUTCOME:-unknown}" == "dirty_handoff" || "${OUTCOME:-unknown}" == "committed_handoff" || "${JOB_STATUS:-}" != "success" ]]; then POST_HANDOFF=true fi fi @@ -7203,6 +7229,47 @@ jobs: MARK_ROUND="${MAX_ROUNDS}" fi else + if [[ "${OUTCOME:-}" == 'handoff' ]]; then + # A deliberate stop, not a failed fix: the agent stopped + # under instruction and deferred the item to a human. No + # stale-base retry (there is no fix to re-attempt) and the + # headline says what actually happened — the old + # "could not produce a passing fix" wording reported the + # brake's decision as a failure and buried the handoff. + # Wording guard: no "🤖 AutoFix stopped" prefix — the fleet + # shepherd's REASON regex reads that as a TERMINAL stop + # reason, and this stop is transient (the loop stays + # engaged); the shepherd contract test pins the distinction. + HEADLINE="🤖 AutoFix deferred this item to a human under instruction (round ${MARK_ROUND}/${MAX_ROUNDS}) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + HEADLINE_ZH="🤖 AutoFix 已按指示将此项移交人工处理(第 ${MARK_ROUND}/${MAX_ROUNDS} 轮)—— 下方 agent 的 handoff 说明列出了待决决策与各选项。循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。" + elif [[ "${OUTCOME:-}" == 'dirty_handoff' ]]; then + # A brake VIOLATION, not a failed fix: the agent stopped + # under instruction but left a dirty workspace, so the + # gate rejected the round under its own outcome (never + # retryable — a repair pass would commit against the + # brake). No stale-base probe: there is no fix to + # re-attempt, and the probe's update-branch would merge + # main for a retry that must not happen. The watermark + # still advances (the feedback WAS read), so the item + # hands to a human without any automatic-retry promise. + # Wording guard: no "🤖 AutoFix stopped" prefix, same + # reason as the handoff branch above. + HEADLINE="🤖 AutoFix rejected this round — the agent wrote a handoff but left a dirty workspace, violating the brake's commit-nothing stop (round ${MARK_ROUND}/${MAX_ROUNDS}). Nothing was committed; the uncommitted work was discarded with the runner. The handoff note below names what the agent stopped on, and the run log lists the dirty files. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + HEADLINE_ZH="🤖 AutoFix 拒绝了本轮 —— agent 写了 handoff 却留下了脏工作区,违反 brake 的禁止提交停止(第 ${MARK_ROUND}/${MAX_ROUNDS} 轮)。未提交任何内容;未提交的改动已随 runner 丢弃。下方 handoff 说明指出了 agent 停止时的状态,运行日志列出了脏文件。循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。" + elif [[ "${OUTCOME:-}" == 'committed_handoff' ]]; then + # The committed sibling of the dirty-handoff violation: + # the round HAS a commit beside handoff.md. The gate + # rejected it non-retryably under its own outcome, so the + # repair pass never engages and the handoff note survives + # to be posted. The runner-side commit was NOT pushed — + # only 'Push and report' (fixed/noop) publishes — so it is + # discarded with the runner, the same committed_rc the + # failure.md "commit discarded" wording keys on. + # Wording guard: no "🤖 AutoFix stopped" prefix, same + # reason as the handoff branch above. + HEADLINE="🤖 AutoFix rejected this round — the agent wrote a handoff but the round HAS a commit, violating the brake's commit-nothing stop (round ${MARK_ROUND}/${MAX_ROUNDS}). The commit was NOT pushed and is discarded with the runner; the run log lists it. The handoff note below names what the agent stopped on. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." + HEADLINE_ZH="🤖 AutoFix 拒绝了本轮 —— agent 写了 handoff 但本轮存在提交,违反 brake 的禁止提交停止(第 ${MARK_ROUND}/${MAX_ROUNDS} 轮)。该提交未推送,已随 runner 丢弃;运行日志中有记录。下方 handoff 说明指出了 agent 停止时的状态。循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。" + else # The gate ran and rejected the agent's fix (a build/test # failure). Before handing to a human, check whether the PR is # merely BEHIND main: a build that fails on something main @@ -7281,6 +7348,7 @@ jobs: HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS})${GATE_CLAUSE}. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own." HEADLINE_ZH="🤖 未能为该反馈产生可通过验证的修复(第 ${MARK_ROUND}/${MAX_ROUNDS} 轮)${GATE_CLAUSE_ZH}。此项现在需要人工处理;循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。" fi + fi fi elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then # NEWEST is empty because Prepare never RAN TO A VERDICT — an @@ -7386,8 +7454,8 @@ jobs: done <<< "${PRIOR_HEADS}" if [[ "${CONSEC_FAIL}" -ge "${CONSECUTIVE_FAILURE_CAP}" ]]; then MARK_ROUND="${MAX_ROUNDS}" - HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that failed to push anything (timeouts and/or gate rejections). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." - HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮未能推送任何内容(超时和/或验证门拒绝)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 PR。" + HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that pushed nothing (failed rounds, timeouts, gate rejections, or stops under instruction). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮未能推送任何内容(失败轮次、超时、验证门拒绝或按指示停止)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 PR。" fi # CUMULATIVE timeout breaker — the sibling of the consecutive # one above, for the failure shape it cannot see: timeouts @@ -7672,9 +7740,12 @@ jobs: if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" fi - # 'fixed'/'noop' are the two outcomes that published a round report; - # anything else means the round stopped before publishing one. - if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' ]]; then + # 'fixed'/'noop'/'handoff'/'dirty_handoff'/'committed_handoff' are + # the outcomes that published a round report — handoff and the two + # brake-violation rejections post their note + eval marker from the + # report step; anything else means the round stopped before + # publishing one. + if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' || "${OUTCOME:-}" == 'dirty_handoff' || "${OUTCOME:-}" == 'committed_handoff' ]]; then EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")" ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")" else diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 37171d6d646..8f7a9f83ab9 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -373,6 +373,24 @@ describe('qwen-autofix workflow', () => { expect(workflow).not.toMatch(/^\s*(fi|done|esac)"\s*$/m); }); + it('keeps the recovery clone byte-identical to the original workflow', () => { + // The recovery clone is a byte-identical copy of qwen-autofix.yml under + // a new path (its header names its deletion condition), except two + // comment blocks: its own RECOVERY CLONE header and the original's + // entity-specific no-op re-parse touch. It stages and executes the SAME + // shared gate script, so a consumer update to one file (the gate's + // handoff outcomes) must land in BOTH — drift once made the live clone + // fail rounds the gate deliberately ended green. + const recoveryPath = '.github/workflows/qwen-autofix-recovery.yml'; + if (!existsSync(recoveryPath)) return; // its deletion condition was met + const recovery = readFileSync(recoveryPath, 'utf8'); + const stripCloneHeader = (t) => + t.replace(/^# RECOVERY CLONE[\s\S]*?^#\n/m, ''); + const stripNoOpTouch = (t) => + t.replace(/^# 2026-08-19: no-op comment[\s\S]*?~12 hours\.\n/m, ''); + expect(stripCloneHeader(recovery)).toBe(stripNoOpTouch(workflow)); + }); + it('keeps the prepare-branch-and-feedback run block bash-parseable', () => { // The deferred-feedback echo sits inside a double-quoted string, where the // '"'"' idiom is a literal apostrophe followed by a string CLOSER rather