From 057aead975a4ce470ddc344d6550a930fcdcfc26 Mon Sep 17 00:00:00 2001 From: verify Date: Wed, 29 Jul 2026 18:23:17 +0800 Subject: [PATCH 1/6] fix(autofix): stop repeat-timeout PRs with a cumulative breaker and narrow the retry prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consecutive-failure cap resets on every pushed round, so it never fires on the observed failure shape: timeouts interleaved with successes. #7929 burned three 50-minute agent timeouts (with pushed rounds in between) and #7846 two — each a full agent budget with nothing to show, invisible to the breaker by construction. Three changes: - Cumulative timeout breaker (TIMEOUT_WINDOW_CAP=3): time-budget exhaustions are counted over the whole counting window, successes in between included. At the cap the PR goes terminal with a skip-until-/retry headline, exactly like the consecutive breaker. The census reuses PRIOR_HEADS, so it is window-scoped and a re-arm clears it. - Prompt narrowing from the second attempt: when the current window already contains a timeout marker, the prepare step appends a budget warning to feedback.md — smallest blocking subset first, commit early, defer the rest explicitly — instead of re-running the identical address-everything prompt into the same wall. - Truthful gate-rejection handoff: 'A human should take over this PR' read as a full release, but the loop keeps managing the PR (new feedback, base conflicts) — #7929 posted it and then kept pushing rounds. The headline now says exactly which half is over: this item will not be retried automatically, management continues. --- .github/workflows/qwen-autofix.yml | 63 ++++++++++++- scripts/tests/qwen-autofix-workflow.test.js | 99 +++++++++++++++++++-- 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index cc65d6dbb28..8f8627380fe 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -190,6 +190,16 @@ env: # unbroken run of failures. Observed on #6723: 7 straight failed rounds (3 # timeouts, 4 gate rejections) over 8 hours, heading for 100. CONSECUTIVE_FAILURE_CAP: '5' + # Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for + # the failure shape it cannot see: timeouts INTERLEAVED with successful + # rounds. A success resets the consecutive streak, but it does not make the + # next timeout any cheaper — each one burns a full agent budget (~50m of + # runner time) and pushes nothing. Observed on #7929: three timeouts with + # pushed rounds in between, so the consecutive cap never fired and the PR + # kept walking into the same wall; #7846 the same, twice. Counted over the + # current counting window (window-scoped like every other census), so a + # re-arm clears it along with the round counter. + TIMEOUT_WINDOW_CAP: '3' # Do not claim more issues when too many existing autofix PRs are still open. MAX_OPEN_AUTOFIX_PRS: '5' @@ -2998,6 +3008,30 @@ jobs: echo printf '%s\n' "${LAST_REJECTION}" fi + # Repeat time-budget exhaustions mean the standard + # address-everything prompt is not converging at this budget: + # re-running it unchanged just walks into the same wall (#7929 + # burned three 50-minute timeouts that way, #7846 two — each a + # full agent run with nothing pushed). From the second attempt + # on, tell the agent to narrow. Window-scoped like every other + # census (LIVE_REARM_KEY is the live window), so a re-arm + # clears it. + PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.body // "") | contains("")) + or ($key == "none" and (((.body // "") | contains("win=")) | not))) + | select(((.body // "") | gsub("\r"; "") | split("\n")[0]) | contains("ran out of time")) + ] | length' "${WORKDIR}/ic.json")" + if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]; then + echo + echo '## Budget warning: previous round(s) ran out of time' + echo + echo "${PRIOR_TIMEOUTS} earlier round(s) on this PR exhausted the agent time budget before finishing anything. Do NOT attempt everything at once this round:" + echo '- Address the smallest set of blocking (Critical) findings first, and commit as soon as that subset is done.' + echo '- Prefer minimal, focused diffs; skip refactors and nice-to-haves entirely.' + echo '- If the remaining feedback cannot fit the budget, explicitly defer it in your summary instead of running out of time with nothing committed.' + fi } > "${WORKDIR}/feedback.md" echo '--- feedback.md ---' cat "${WORKDIR}/feedback.md" @@ -3783,7 +3817,14 @@ jobs: MARK_TS='9999-12-31T23:59:59Z' HEADLINE="🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind \`${DEFAULT_BRANCH:-main}\`, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human." else - HEADLINE="🤖 Could not address the latest feedback automatically (round ${MARK_ROUND}/${MAX_ROUNDS}). A human should take over this PR." + # Say what actually happens next. The old "A human should + # take over this PR" read as a full release, but the loop + # is NOT done with the PR: this feedback's watermark + # advances (no automatic retry of THIS item), while + # management continues for new feedback and base conflicts + # — #7929 posted the old wording and then kept pushing + # rounds, which read as a contradiction. + HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS}) — the verification gate rejected the attempt. 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 elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then @@ -3884,6 +3925,26 @@ jobs: 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." fi + # CUMULATIVE timeout breaker — the sibling of the consecutive + # one above, for the failure shape it cannot see: timeouts + # interleaved with pushed rounds. A push resets CONSEC_FAIL, + # but it does not make the next timeout cheaper — each burns a + # full agent budget with nothing to show (observed on #7929: + # three timeouts with successes in between; #7846 twice). The + # census reuses PRIOR_HEADS, so it is window-scoped exactly + # like the consecutive one and a re-arm clears it. Only + # overrides a would-be RETRY: a round already terminal keeps + # its own headline (the consecutive breaker included). + if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]]; then + TIMEOUT_N="$(grep -c 'ran out of time' <<< "${PRIOR_HEADS}" || true)" + if [[ -n "${AGENT_TIMEOUT:-}" ]]; then + TIMEOUT_N=$(( TIMEOUT_N + 1 )) + fi + if [[ "${TIMEOUT_N}" -ge "${TIMEOUT_WINDOW_CAP}" ]]; then + MARK_ROUND="${MAX_ROUNDS}" + HEADLINE="🤖 AutoFix stopped after ${TIMEOUT_N} time-budget exhaustions in this counting window (not consecutive — pushed rounds in between do not make the next timeout cheaper). Re-running the same per-round budget against this PR is not converging; each timeout burns a full agent run with nothing pushed. A human should split or reduce the PR (or raise the agent time budget), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + fi + fi fi { echo "${HEADLINE}" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 094e4ebabdc..596e7272dff 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -3205,6 +3205,35 @@ describe('qwen-autofix workflow', () => { expect(broken.body).toBe(''); }); + it('narrows the agent prompt after a prior in-window timeout', () => { + // Re-running the identical address-everything prompt after a timeout + // walks straight into the same wall (#7929 burned three 50-minute + // timeouts that way, #7846 two). From the second attempt on, the + // feedback file ends with an explicit narrowing instruction: smallest + // blocking subset first, commit early, defer the rest out loud. + expect(prepareBranchAndFeedbackStep).toContain('PRIOR_TIMEOUTS='); + expect(prepareBranchAndFeedbackStep).toContain( + 'Budget warning: previous round(s) ran out of time', + ); + expect(prepareBranchAndFeedbackStep).toContain( + 'commit as soon as that subset is done', + ); + // The census is window-scoped (live window key, legacy 'none' + // included) so a re-arm clears it like every other census. + expect(prepareBranchAndFeedbackStep).toContain('win=" + $key + " -->'); + expect(prepareBranchAndFeedbackStep).toContain( + '--arg key "${LIVE_REARM_KEY}"', + ); + // The census needle must match the timeout CAUSE the report step emits, + // or it silently counts zero. + expect(prepareBranchAndFeedbackStep).toContain( + 'contains("ran out of time")', + ); + expect(reviewAddressReportStep).toContain( + 'ran out of time before finishing', + ); + }); + it('switches to Critical-only feedback after five change rounds', () => { // ROUND counts change-producing rounds, so 4 still starts the fifth // suggestion-capable change while 5 starts the first Critical-only round. @@ -5147,7 +5176,16 @@ describe('qwen-autofix workflow', () => { 'for f in failure.md handoff.md address-summary.md no-action.md', ); expect(reviewAddressReportStep).toContain( - 'Could not address the latest feedback automatically', + 'Could not produce a passing fix for this feedback', + ); + // The handoff must not read as a full release: the loop stays engaged + // for NEW feedback (#7929 posted "a human should take over" and then + // kept pushing rounds — a contradiction to anyone reading the thread). + expect(reviewAddressReportStep).toContain( + 'the loop stays engaged and still picks up new feedback', + ); + expect(reviewAddressReportStep).toContain( + 'will not retry this item on its own', ); expect(reviewAddressReportStep).toContain('gh pr comment "${PR}"'); expect(reviewAddressReportStep).toContain( @@ -5470,7 +5508,8 @@ describe('qwen-autofix workflow', () => { // advance the watermark and hand off to a human. const rejected = run({ OUTCOME: 'failed' }); expect(rejected.split('|')[0]).toBe(NEWEST); - expect(rejected).toContain('Could not address the latest feedback'); + expect(rejected).toContain('Could not produce a passing fix'); + expect(rejected).toContain('stays engaged'); // #7471: the gate rejected the fix, but the PR was BEHIND main — the build // failed on a stale base (a dependency main already removed), not the fix. @@ -5495,7 +5534,7 @@ describe('qwen-autofix workflow', () => { UPDATE_OK_STUB: '0', }); expect(staleConflict.split('|')[0]).toBe(NEWEST); - expect(staleConflict).toContain('Could not address the latest feedback'); + expect(staleConflict).toContain('Could not produce a passing fix'); // Gate crash (no verdict): keep the feedback live and retry. const crashed = run({ OUTCOME: '' }); @@ -5695,6 +5734,12 @@ describe('qwen-autofix workflow', () => { workflow.match(/TAKEOVER_MAX_ROUNDS: '(\d+)'/)?.[1], ); expect(cap).toBeLessThan(takeoverCap); + // Cumulative timeout sub-cap: same constraints as the consecutive one. + const timeoutCap = Number( + workflow.match(/TIMEOUT_WINDOW_CAP: '(\d+)'/)?.[1], + ); + expect(timeoutCap).toBeGreaterThan(0); + expect(timeoutCap).toBeLessThan(takeoverCap); const block = reviewAddressReportStep.match( /if \[\[ "\$\{MARK_ROUND\}" != "\$\{MAX_ROUNDS\}" \]\] && \[\[ "\$\{PREPARE_OUTCOME\}" == 'success' \|\| "\$\{PREPARE_OUTCOME\}" == 'failure' \]\] && \[\[ "\$\{STALE_BASE_RETRY:-false\}" != 'true' \]\] && \{ \[\[ -z "\$\{API_ERROR_DETAIL\}" \]\] \|\| \[\[ "\$\{API_ERROR_KIND\}" == 'auth' \]\]; \}; then\n {14}CONSEC_FAIL=1\n[\s\S]*?\n {14}fi\n {12}fi\n/, @@ -5703,7 +5748,7 @@ describe('qwen-autofix workflow', () => { const script = block.replace(/^ {12}/gm, ''); const FAIL = - '🤖 Could not address the latest feedback automatically (round 3/100).'; + '🤖 Could not produce a passing fix for this feedback (round 3/100) — the verification gate rejected the attempt.'; const FAIL_TIMEOUT = '🤖 AutoFix could not reach the model (attempt 2/3)'; const PUSH = '🤖 Addressed the latest review feedback (round 2/100).'; const NOOP = '🤖 Reviewed the latest feedback — no changes needed.'; @@ -5725,6 +5770,7 @@ describe('qwen-autofix workflow', () => { apiErrorKind = '', prepareOutcome = 'success', staleBaseRetry = false, + agentTimeout = '', } = {}, ) => { const dir = mkdtempSync(join(tmpdir(), 'consec-')); @@ -5753,7 +5799,7 @@ describe('qwen-autofix workflow', () => { 'bash', [ '-c', - `set -uo pipefail\nWORKDIR='${dir}'\nMARK_ROUND=${markRound}\nMAX_ROUNDS=100\nCONSECUTIVE_FAILURE_CAP=${cap}\nCONSEC_FAIL=0\nREPO=o/r\nPR=1\nAUTOFIX_BOT=qwen-code-dev-bot\nRETRY_COMMAND='@qwen-code /retry'\nAPI_ERROR_DETAIL='${apiErrorDetail}'\nAPI_ERROR_KIND='${apiErrorKind}'\nPREPARE_OUTCOME='${prepareOutcome}'\nSTALE_BASE_RETRY='${staleBaseRetry}'\n${window !== undefined ? `WINDOW='${window}'\n` : ''}HEADLINE=orig\n${script}\nprintf '%s|%s|%s' "$MARK_ROUND" "${'${CONSEC_FAIL}'}" "$HEADLINE"`, + `set -uo pipefail\nWORKDIR='${dir}'\nMARK_ROUND=${markRound}\nMAX_ROUNDS=100\nCONSECUTIVE_FAILURE_CAP=${cap}\nTIMEOUT_WINDOW_CAP=${timeoutCap}\nAGENT_TIMEOUT='${agentTimeout}'\nCONSEC_FAIL=0\nREPO=o/r\nPR=1\nAUTOFIX_BOT=qwen-code-dev-bot\nRETRY_COMMAND='@qwen-code /retry'\nAPI_ERROR_DETAIL='${apiErrorDetail}'\nAPI_ERROR_KIND='${apiErrorKind}'\nPREPARE_OUTCOME='${prepareOutcome}'\nSTALE_BASE_RETRY='${staleBaseRetry}'\n${window !== undefined ? `WINDOW='${window}'\n` : ''}HEADLINE=orig\n${script}\nprintf '%s|%s|%s' "$MARK_ROUND" "${'${CONSEC_FAIL}'}" "$HEADLINE"`, ], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, @@ -5863,6 +5909,49 @@ describe('qwen-autofix workflow', () => { terminal: true, headline: 'orig', }); + + // CUMULATIVE timeout breaker: pushes in between reset CONSEC_FAIL but + // must NOT reset this one — #7929's exact shape (timeout, push, timeout, + // push, timeout) never tripped the consecutive cap while burning a full + // agent budget each time. + const TIMEOUT_HEAD = + '🤖 AutoFix ran out of time before finishing (timeout (3000000ms)) (attempt 2/100) — it will retry on the next scan.'; + const interleaved = run([TIMEOUT_HEAD, PUSH, TIMEOUT_HEAD, PUSH], { + agentTimeout: 'timeout (3000000ms)', + }); + expect(interleaved.terminal).toBe(true); + expect(interleaved.headline).toContain('time-budget exhaustions'); + expect(interleaved.headline).toContain('/retry'); + // One short of the cap keeps retrying (current round not a timeout). + expect(run([TIMEOUT_HEAD, PUSH, TIMEOUT_HEAD])).toMatchObject({ + terminal: false, + }); + // Window-scoped like every other census: pre-re-arm timeouts don't count. + expect( + run( + [ + { headline: TIMEOUT_HEAD, win: 'old-window' }, + { headline: TIMEOUT_HEAD, win: 'old-window' }, + ], + { window: 'new-window', agentTimeout: 'timeout (3000000ms)' }, + ), + ).toMatchObject({ terminal: false }); + // When BOTH breakers would fire, the consecutive one (evaluated first) + // keeps its headline — a terminal round is never overridden. + const bothCapped = run(Array(cap - 1).fill(TIMEOUT_HEAD), { + agentTimeout: 'timeout (3000000ms)', + }); + expect(bothCapped.terminal).toBe(true); + expect(bothCapped.headline).toContain('consecutive'); + // Pin the census greps to the actual emit line: the timeout CAUSE text + // and the breaker's grep needle must stay in lockstep, or the census + // silently counts zero. + expect(reviewAddressReportStep).toContain( + 'CAUSE="ran out of time before finishing (${AGENT_TIMEOUT})"', + ); + expect(reviewAddressReportStep).toContain( + 'TIMEOUT_N="$(grep -c \'ran out of time\' <<< "${PRIOR_HEADS}" || true)"', + ); // The reset detector keys on literal substrings; pin them to the actual // "Push and report" emit lines so a reword breaks this test, not silently // the streak reset in production. From f9f9fd11d3bcef06d5d8ccdd681a645038f3aaa6 Mon Sep 17 00:00:00 2001 From: verify Date: Wed, 29 Jul 2026 20:17:28 +0800 Subject: [PATCH 2/6] fix(autofix): timeout-breaker review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six review findings addressed: - The narrowing prompt now restates the SKILL deferral contract instead of pointing at 'your summary': deferred findings stay out of resolved-comments.txt and get a comment-replies.json entry so every open thread carries its reason — the summary-only shortcut is exactly what a budget-pressured agent would otherwise take. 'skip refactors entirely' became 'decline with a one-line reason' for the same never-drop-silently rule. - The narrowing census counts timeouts SINCE THE LAST SUCCESSFUL ROUND, not cumulatively: a push falsifies 'not converging', and one old timeout must not degrade every later round of a 100-round window. The breaker stays cumulative (a push does not make the next timeout cheaper in budget terms). - The breaker headline states what the census measured ('this counting window now contains N time-budget exhaustions') instead of 'stopped after N' — the tripping round can itself be a gate rejection, which is the exact rollout state of #7929/#7846. - Both census needles anchor on the verbatim emitted headline ('AutoFix ran out of time before finishing') — first lines can embed provider error text, so the loose phrase could count a model error as a timeout. - The precedence assertion got teeth: 'consecutive' alone matched both branches; it now asserts the consecutive breaker's own phrase and the absence of the timeout one (the if-true guard mutation now fails). - The narrowing census got a behavioral replay over fixture ic.json: push-resets, trailing counts, legacy no-win markers under 'none', old-window exclusion, author filtering — plus a pin on the -ge 1 trigger so a threshold mutation cannot leave the feature inert. --- .github/workflows/qwen-autofix.yml | 50 ++++--- scripts/tests/qwen-autofix-workflow.test.js | 137 ++++++++++++++++++-- 2 files changed, 160 insertions(+), 27 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 8f8627380fe..9d297e33bbf 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3008,29 +3008,39 @@ jobs: echo printf '%s\n' "${LAST_REJECTION}" fi - # Repeat time-budget exhaustions mean the standard - # address-everything prompt is not converging at this budget: - # re-running it unchanged just walks into the same wall (#7929 - # burned three 50-minute timeouts that way, #7846 two — each a - # full agent run with nothing pushed). From the second attempt - # on, tell the agent to narrow. Window-scoped like every other - # census (LIVE_REARM_KEY is the live window), so a re-arm - # clears it. + # Time-budget exhaustions SINCE THE LAST SUCCESSFUL ROUND mean + # the standard address-everything prompt is not converging at + # this budget: re-running it unchanged just walks into the same + # wall (#7929 burned three 50-minute timeouts that way, #7846 + # two — each a full agent run with nothing pushed). From the + # second attempt on, tell the agent to narrow. Counted since + # the last pushed/no-change round, NOT cumulatively: a push + # falsifies "not converging", and a single old timeout must not + # degrade every later round in a 100-round window. (The + # BREAKER in the report step stays cumulative — a push does not + # make the next timeout cheaper in budget terms.) Window-scoped + # like every other census (LIVE_REARM_KEY is the live window), + # so a re-arm clears it. The needle matches the emitted + # headline verbatim: first lines can embed provider error text + # (API_ERROR_DETAIL), so a loose phrase could count a model + # error message as a timeout. PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" ' [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) or ($key == "none" and (((.body // "") | contains("win=")) | not))) - | select(((.body // "") | gsub("\r"; "") | split("\n")[0]) | contains("ran out of time")) - ] | length' "${WORKDIR}/ic.json")" + ] | sort_by(.created_at) + | map((.body | gsub("\r"; "") | split("\n")[0])) + | (map(test("Addressed the latest review feedback|no changes needed")) | rindex(true) // -1) as $lastok + | [ .[($lastok + 1):][] | select(contains("AutoFix ran out of time before finishing")) ] | length' "${WORKDIR}/ic.json")" if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]; then echo echo '## Budget warning: previous round(s) ran out of time' echo - echo "${PRIOR_TIMEOUTS} earlier round(s) on this PR exhausted the agent time budget before finishing anything. Do NOT attempt everything at once this round:" + echo "${PRIOR_TIMEOUTS} round(s) since the last successful round exhausted the agent time budget before finishing anything. Do NOT attempt everything at once this round:" echo '- Address the smallest set of blocking (Critical) findings first, and commit as soon as that subset is done.' - echo '- Prefer minimal, focused diffs; skip refactors and nice-to-haves entirely.' - echo '- If the remaining feedback cannot fit the budget, explicitly defer it in your summary instead of running out of time with nothing committed.' + echo '- Prefer minimal, focused diffs; decline refactors and nice-to-haves with a one-line reason rather than implementing them.' + echo '- If the remaining feedback cannot fit the budget, defer it the normal way: leave those findings out of resolved-comments.txt and record each deferral in comment-replies.json so every open thread gets its reason — never let a deferral live only in the summary.' fi } > "${WORKDIR}/feedback.md" echo '--- feedback.md ---' @@ -3936,13 +3946,23 @@ jobs: # overrides a would-be RETRY: a round already terminal keeps # its own headline (the consecutive breaker included). if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]]; then - TIMEOUT_N="$(grep -c 'ran out of time' <<< "${PRIOR_HEADS}" || true)" + # Needle matches the emitted headline verbatim — first lines + # can embed provider error text (API_ERROR_DETAIL puts up to + # 200 bytes of it on the same line), so a loose phrase could + # count a model error message as a timeout. + TIMEOUT_N="$(grep -c 'AutoFix ran out of time before finishing' <<< "${PRIOR_HEADS}" || true)" if [[ -n "${AGENT_TIMEOUT:-}" ]]; then TIMEOUT_N=$(( TIMEOUT_N + 1 )) fi if [[ "${TIMEOUT_N}" -ge "${TIMEOUT_WINDOW_CAP}" ]]; then MARK_ROUND="${MAX_ROUNDS}" - HEADLINE="🤖 AutoFix stopped after ${TIMEOUT_N} time-budget exhaustions in this counting window (not consecutive — pushed rounds in between do not make the next timeout cheaper). Re-running the same per-round budget against this PR is not converging; each timeout burns a full agent run with nothing pushed. A human should split or reduce the PR (or raise the agent time budget), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + # The headline states what the census MEASURED — the + # window's cumulative count — not "stopped after N + # timeouts": the round that trips this can itself have + # failed differently (a gate rejection landing on a window + # that already carries the cap — the exact rollout state + # of #7929/#7846). + HEADLINE="🤖 AutoFix stopped: this counting window now contains ${TIMEOUT_N} time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). Re-running the same per-round budget against this PR is not converging; each timeout burns a full agent run with nothing pushed. A human should split or reduce the PR (or raise the agent time budget), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." fi fi fi diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 596e7272dff..1569020891c 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -3205,12 +3205,15 @@ describe('qwen-autofix workflow', () => { expect(broken.body).toBe(''); }); - it('narrows the agent prompt after a prior in-window timeout', () => { + it('narrows the agent prompt after a timeout since the last successful round', () => { // Re-running the identical address-everything prompt after a timeout // walks straight into the same wall (#7929 burned three 50-minute // timeouts that way, #7846 two). From the second attempt on, the // feedback file ends with an explicit narrowing instruction: smallest - // blocking subset first, commit early, defer the rest out loud. + // blocking subset first, commit early, and defer through the SKILL + // contract (out of resolved-comments.txt, into comment-replies.json) + // — never only into the summary, which is exactly the cheap path a + // budget-pressured agent would otherwise take. expect(prepareBranchAndFeedbackStep).toContain('PRIOR_TIMEOUTS='); expect(prepareBranchAndFeedbackStep).toContain( 'Budget warning: previous round(s) ran out of time', @@ -3218,19 +3221,122 @@ describe('qwen-autofix workflow', () => { expect(prepareBranchAndFeedbackStep).toContain( 'commit as soon as that subset is done', ); - // The census is window-scoped (live window key, legacy 'none' - // included) so a re-arm clears it like every other census. - expect(prepareBranchAndFeedbackStep).toContain('win=" + $key + " -->'); expect(prepareBranchAndFeedbackStep).toContain( - '--arg key "${LIVE_REARM_KEY}"', + 'record each deferral in comment-replies.json', ); - // The census needle must match the timeout CAUSE the report step emits, - // or it silently counts zero. expect(prepareBranchAndFeedbackStep).toContain( - 'contains("ran out of time")', + 'decline refactors and nice-to-haves with a one-line reason', + ); + // The trigger threshold itself is pinned — a `-ge 99` mutation would + // otherwise leave the feature inert with every string pin green. + expect(prepareBranchAndFeedbackStep).toContain( + 'if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]', + ); + + // Behavioral replay of the census (the string pins alone cannot catch + // a broken filter): extract the real jq and run it against fixture + // ic.json shapes. + const censusSrc = prepareBranchAndFeedbackStep.match( + /PRIOR_TIMEOUTS="\$\(jq -r[\s\S]*?ic\.json"\)"/, + )?.[0]; + expect(censusSrc).toBeTruthy(); + const TIMEOUT_HEADLINE = + '🤖 AutoFix ran out of time before finishing (timeout (3000000ms)) (attempt 2/100) — it will retry on the next scan.'; + const PUSH_HEADLINE = + '🤖 Addressed the latest review feedback (round 2/100). What changed…'; + const mk = (headline, win, at, login = 'qwen-code-dev-bot') => ({ + user: { login }, + created_at: at, + body: `${headline}\n`, + }); + const runCensus = (comments, key) => { + const dir = mkdtempSync(join(tmpdir(), 'timeout-census-')); + try { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); + return execFileSync( + 'bash', + [ + '-c', + [ + 'set -uo pipefail', + `WORKDIR='${dir}'`, + "AUTOFIX_BOT='qwen-code-dev-bot'", + `LIVE_REARM_KEY='${key}'`, + censusSrc, + 'printf %s "${PRIOR_TIMEOUTS}"', + ].join('\n'), + ], + { encoding: 'utf8' }, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const K = '2026-07-29T03:00:00Z'; + // A push RESETS the narrowing (the breaker stays cumulative — this + // census feeds only the prompt): timeout, push, timeout → 1. + expect( + runCensus( + [ + mk(TIMEOUT_HEADLINE, K, '2026-07-29T04:00:00Z'), + mk(PUSH_HEADLINE, K, '2026-07-29T05:00:00Z'), + mk(TIMEOUT_HEADLINE, K, '2026-07-29T06:00:00Z'), + ], + K, + ), + ).toBe('1'); + // Two trailing timeouts count as two. + expect( + runCensus( + [ + mk(PUSH_HEADLINE, K, '2026-07-29T04:00:00Z'), + mk(TIMEOUT_HEADLINE, K, '2026-07-29T05:00:00Z'), + mk(TIMEOUT_HEADLINE, K, '2026-07-29T06:00:00Z'), + ], + K, + ), + ).toBe('2'); + // Legacy pre-takeover markers (no win= field) count under key 'none' — + // the common real case: a PR that timed out before any re-arm. + expect( + runCensus( + [ + mk(TIMEOUT_HEADLINE, null, '2026-07-29T04:00:00Z'), + mk(TIMEOUT_HEADLINE, null, '2026-07-29T05:00:00Z'), + ], + 'none', + ), + ).toBe('2'); + // Old-window timeouts do not leak into a fresh window (re-arm clears). + expect( + runCensus( + [ + mk(TIMEOUT_HEADLINE, 'old-window', '2026-07-29T04:00:00Z'), + mk(TIMEOUT_HEADLINE, 'old-window', '2026-07-29T05:00:00Z'), + ], + K, + ), + ).toBe('0'); + // Non-timeout rounds and a HUMAN quoting the timeout headline verbatim + // both count zero (author-filtered like every census). + expect(runCensus([mk(PUSH_HEADLINE, K, '2026-07-29T04:00:00Z')], K)).toBe( + '0', + ); + expect( + runCensus( + [mk(TIMEOUT_HEADLINE, K, '2026-07-29T04:00:00Z', 'some-human')], + K, + ), + ).toBe('0'); + + // The census needle matches the emitted headline VERBATIM — first + // lines can embed provider error text (API_ERROR_DETAIL), so a loose + // phrase could count a model error message as a timeout. + expect(prepareBranchAndFeedbackStep).toContain( + 'contains("AutoFix ran out of time before finishing")', ); expect(reviewAddressReportStep).toContain( - 'ran out of time before finishing', + 'CAUSE="ran out of time before finishing (${AGENT_TIMEOUT})"', ); }); @@ -5942,7 +6048,14 @@ describe('qwen-autofix workflow', () => { agentTimeout: 'timeout (3000000ms)', }); expect(bothCapped.terminal).toBe(true); - expect(bothCapped.headline).toContain('consecutive'); + // 'consecutive' alone is satisfied by EITHER branch; assert the + // consecutive breaker's own phrase AND the absence of the timeout one + // — 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', + ); + expect(bothCapped.headline).not.toContain('time-budget exhaustions'); // Pin the census greps to the actual emit line: the timeout CAUSE text // and the breaker's grep needle must stay in lockstep, or the census // silently counts zero. @@ -5950,7 +6063,7 @@ describe('qwen-autofix workflow', () => { 'CAUSE="ran out of time before finishing (${AGENT_TIMEOUT})"', ); expect(reviewAddressReportStep).toContain( - 'TIMEOUT_N="$(grep -c \'ran out of time\' <<< "${PRIOR_HEADS}" || true)"', + 'TIMEOUT_N="$(grep -c \'AutoFix ran out of time before finishing\' <<< "${PRIOR_HEADS}" || true)"', ); // The reset detector keys on literal substrings; pin them to the actual // "Push and report" emit lines so a reword breaks this test, not silently From 7f32419245881874405405877cf966a957f1a303 Mon Sep 17 00:00:00 2001 From: verify Date: Wed, 29 Jul 2026 20:50:51 +0800 Subject: [PATCH 3/6] fix(autofix): state what the timeout breaker measured, pin the inherited guarantees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round review follow-ups: - The breaker headline no longer infers 'is not converging' — a window can carry 40 pushes and still trip the (deliberately cumulative) cap, so the sentence now states only what the census measured: N full agent runs that pushed nothing. - Two guardrail tests pin behavior that held only by construction: a non-timeout failure landing on an already-capped window still trips the breaker (the documented rollout state — an 'only count when this round timed out' cleanup would silently delete it), and the transient-API-error exemption inherited from the outer guard (a refactor hoisting the block out of it must not mass-terminate every in-flight PR during a provider outage). --- .github/workflows/qwen-autofix.yml | 2 +- scripts/tests/qwen-autofix-workflow.test.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 9d297e33bbf..b8abfa907a4 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3962,7 +3962,7 @@ jobs: # failed differently (a gate rejection landing on a window # that already carries the cap — the exact rollout state # of #7929/#7846). - HEADLINE="🤖 AutoFix stopped: this counting window now contains ${TIMEOUT_N} time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). Re-running the same per-round budget against this PR is not converging; each timeout burns a full agent run with nothing pushed. A human should split or reduce the PR (or raise the agent time budget), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + HEADLINE="🤖 AutoFix stopped: this counting window now contains ${TIMEOUT_N} time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). That is ${TIMEOUT_N} full agent runs that pushed nothing. A human should split or reduce the PR (or raise the agent time budget), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." fi fi fi diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 1569020891c..1a4c65de674 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -6042,6 +6042,23 @@ describe('qwen-autofix workflow', () => { { window: 'new-window', agentTimeout: 'timeout (3000000ms)' }, ), ).toMatchObject({ terminal: false }); + // A NON-timeout failure landing on an already-capped window still + // trips it — the #7929/#7846 rollout state the headline's + // parenthetical describes. A plausible "only count when this round + // timed out" cleanup (wrapping the block in an AGENT_TIMEOUT check) + // would silently delete this documented case. + expect( + run([TIMEOUT_HEAD, PUSH, TIMEOUT_HEAD, PUSH, TIMEOUT_HEAD]), + ).toMatchObject({ terminal: true }); + // Inherited from the outer guard, pinned so a refactor that hoists the + // timeout block out of it cannot mass-terminate every in-flight PR + // during a provider outage. + expect( + run(Array(5).fill(TIMEOUT_HEAD), { + apiErrorDetail: '429 rate limited', + apiErrorKind: 'transient', + }), + ).toMatchObject({ terminal: false }); // When BOTH breakers would fire, the consecutive one (evaluated first) // keeps its headline — a terminal round is never overridden. const bothCapped = run(Array(cap - 1).fill(TIMEOUT_HEAD), { From 423c66d5ce0c8ad00981326441465d74b30a4790 Mon Sep 17 00:00:00 2001 From: verify Date: Wed, 29 Jul 2026 13:40:55 +0000 Subject: [PATCH 4/6] fix(autofix): name a gate rejection only when the gate ran; pin the timeout breaker's stale-base exemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-round review follow-ups: - The handoff headline no longer claims 'the verification gate rejected the attempt' for every outcome=failed verdict: that branch is reached for five distinct failures (failure.md abort, dirty tree, unchanged branch, missing summary, and a real gate rejection), only one of which is a gate decision. The clause is now gated on gate-rejection.md, which reject_fix is the sole writer of — so the other four paths keep the neutral framing instead of repeating the very wording-doesn't-match-behaviour bug this PR fixes. A replay test pins both halves: the clause appears when gate-rejection.md exists and is absent when it does not. - The timeout breaker's stale-base exemption is now pinned by a replay (five in-window timeouts plus a stale-base retry current round stays retryable), matching the existing transient-API-error pin: a refactor hoisting the timeout block out of the outer guard would otherwise delete this exemption silently. - The prepare-step narrowing census jq now fails open (2> /dev/null || true) like its report-step sibling, so a malformed ic.json drops the budget warning instead of aborting prepare under errexit and turning the round into a terminal 'could not start evaluation' handoff. - The decay comment now matches the code: the warning resets on a push/no-op round but fires on every failing round until then (gate rejections included), which is correct since nothing has converged. --- .github/workflows/qwen-autofix.yml | 18 ++++++++--- scripts/tests/qwen-autofix-workflow.test.js | 34 +++++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index b8abfa907a4..b7757b4f88f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3015,8 +3015,10 @@ jobs: # two — each a full agent run with nothing pushed). From the # second attempt on, tell the agent to narrow. Counted since # the last pushed/no-change round, NOT cumulatively: a push - # falsifies "not converging", and a single old timeout must not - # degrade every later round in a 100-round window. (The + # falsifies "not converging" and resets the count, so a recovered + # PR stops seeing the warning; until a round pushes or no-ops it + # fires on every failing round (gate rejections included) — + # correctly, since nothing has converged yet. (The # BREAKER in the report step stays cumulative — a push does not # make the next timeout cheaper in budget terms.) Window-scoped # like every other census (LIVE_REARM_KEY is the live window), @@ -3032,7 +3034,7 @@ jobs: ] | sort_by(.created_at) | map((.body | gsub("\r"; "") | split("\n")[0])) | (map(test("Addressed the latest review feedback|no changes needed")) | rindex(true) // -1) as $lastok - | [ .[($lastok + 1):][] | select(contains("AutoFix ran out of time before finishing")) ] | length' "${WORKDIR}/ic.json")" + | [ .[($lastok + 1):][] | select(contains("AutoFix ran out of time before finishing")) ] | length' "${WORKDIR}/ic.json" 2> /dev/null || true)" if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]; then echo echo '## Budget warning: previous round(s) ran out of time' @@ -3834,7 +3836,15 @@ jobs: # management continues for new feedback and base conflicts # — #7929 posted the old wording and then kept pushing # rounds, which read as a contradiction. - HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS}) — the verification gate rejected the attempt. 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." + # Name the gate ONLY when it actually ran: this branch is + # reached for every outcome=failed verdict, but reject_fix + # is the sole writer of gate-rejection.md — the failure.md / + # dirty-tree / unchanged-branch / missing-summary paths made + # no gate decision, so a blanket clause would repeat the very + # wording-doesn't-match-behaviour bug this PR fixes. + GATE_CLAUSE='' + [[ -s "${WORKDIR}/gate-rejection.md" ]] && GATE_CLAUSE=' — the verification gate rejected the attempt' + 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 elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 1a4c65de674..12a3c86c00d 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -3237,7 +3237,7 @@ describe('qwen-autofix workflow', () => { // a broken filter): extract the real jq and run it against fixture // ic.json shapes. const censusSrc = prepareBranchAndFeedbackStep.match( - /PRIOR_TIMEOUTS="\$\(jq -r[\s\S]*?ic\.json"\)"/, + /PRIOR_TIMEOUTS="\$\(jq -r[\s\S]*?ic\.json" 2> \/dev\/null \|\| true\)"/, )?.[0]; expect(censusSrc).toBeTruthy(); const TIMEOUT_HEADLINE = @@ -5558,13 +5558,18 @@ describe('qwen-autofix workflow', () => { expect(decision).toBeTruthy(); const SENTINEL = '9999-12-31T23:59:59Z'; const NEWEST = '2026-07-20T10:00:00Z'; - const run = (env) => { + const run = (env, { gateRejection = false } = {}) => { // The gate-rejection branch (OUTCOME=failed, no crash/timeout) now probes // whether the PR is behind main and, if so, updates the base — so stub gh: // commits/
→ a SHA, compare → CMP_STATUS_STUB (default 'ahead', i.e. // NOT behind, so the existing rejection cases still hand off), and // update-branch → UPDATE_OK_STUB (default success). const dir = mkdtempSync(join(tmpdir(), 'decision-')); + // reject_fix is the ONLY writer of gate-rejection.md, so its presence is + // the exact "the gate actually ran" discriminator the headline keys on. + if (gateRejection) { + writeFileSync(join(dir, 'gate-rejection.md'), '**build failed**'); + } const bin = join(dir, 'bin'); mkdirSync(bin); writeFileSync( @@ -5590,6 +5595,7 @@ describe('qwen-autofix workflow', () => { env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, + WORKDIR: dir, REPO: 'o/r', PR: '1', REPORT_HEAD: 'prhead123', @@ -5612,10 +5618,24 @@ describe('qwen-autofix workflow', () => { // Declared rejection, PR up to date ('ahead') -> a genuine fix failure -> // advance the watermark and hand off to a human. - const rejected = run({ OUTCOME: 'failed' }); + // A genuine gate rejection: reject_fix wrote gate-rejection.md, so the + // headline names the gate. + const rejected = run({ OUTCOME: 'failed' }, { gateRejection: true }); expect(rejected.split('|')[0]).toBe(NEWEST); expect(rejected).toContain('Could not produce a passing fix'); + expect(rejected).toContain('the verification gate rejected the attempt'); expect(rejected).toContain('stays engaged'); + // outcome=failed WITHOUT a gate decision (failure.md abort, dirty tree, + // unchanged branch, or missing summary) must NOT claim the gate rejected: + // gate-rejection.md is written only by reject_fix, so its absence is the + // exact discriminator. A blanket clause would repeat the + // wording-doesn't-match-behaviour bug this PR fixes. + const failedNoGate = run({ OUTCOME: 'failed' }); + expect(failedNoGate.split('|')[0]).toBe(NEWEST); + expect(failedNoGate).toContain('Could not produce a passing fix'); + expect(failedNoGate).not.toContain( + 'the verification gate rejected the attempt', + ); // #7471: the gate rejected the fix, but the PR was BEHIND main — the build // failed on a stale base (a dependency main already removed), not the fix. @@ -6059,6 +6079,14 @@ describe('qwen-autofix workflow', () => { apiErrorKind: 'transient', }), ).toMatchObject({ terminal: false }); + // The timeout breaker ALSO inherits the stale-base exemption from the + // outer guard: a stale-base retry on a window already carrying the + // timeout cap must not terminate (the base was just updated, the next + // round builds fresh). Pinned so the same hoist that would delete the + // API-error exemption above cannot silently delete this one either. + expect( + run(Array(5).fill(TIMEOUT_HEAD), { staleBaseRetry: true }), + ).toMatchObject({ terminal: false }); // When BOTH breakers would fire, the consecutive one (evaluated first) // keeps its headline — a terminal round is never overridden. const bothCapped = run(Array(cap - 1).fill(TIMEOUT_HEAD), { From 263daa6e263ef0615c4aeeb345b8b07340fc7994 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Wed, 29 Jul 2026 17:37:03 +0000 Subject: [PATCH 5/6] fix(autofix): clear stale gate-rejection.md in repair cleanup and pin headline template (#8044) --- .github/workflows/qwen-autofix.yml | 1 + scripts/tests/qwen-autofix-workflow.test.js | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index b7757b4f88f..b30c30b7390 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3268,6 +3268,7 @@ jobs: "${WORKDIR}/failure.md" \ "${WORKDIR}/handoff.md" \ "${WORKDIR}/gate-output.log" \ + "${WORKDIR}/gate-rejection.md" \ "${WORKDIR}/agent-api-error" \ "${WORKDIR}/agent-api-error-kind" \ "${WORKDIR}/agent-timeout" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 12a3c86c00d..e885b6951d8 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -3338,6 +3338,11 @@ describe('qwen-autofix workflow', () => { expect(reviewAddressReportStep).toContain( 'CAUSE="ran out of time before finishing (${AGENT_TIMEOUT})"', ); + // Pin the template that JOINS them: a mutation to the prefix (e.g. + // adding a colon) breaks the census while both pins above stay green. + expect(reviewAddressReportStep).toContain( + 'HEADLINE="🤖 AutoFix ${CAUSE} (attempt', + ); }); it('switches to Critical-only feedback after five change rounds', () => { @@ -5096,7 +5101,7 @@ describe('qwen-autofix workflow', () => { repairDeterministicRejectionStep.match( /rm -f \\\n([\s\S]*?)\n {10}rm -rf "\$\{QWEN_HOME\}"/, )?.[1] ?? ''; - expect(repairCleanup).not.toContain('gate-rejection.md'); + expect(repairCleanup).toContain('gate-rejection.md'); expect(repairDeterministicRejectionStep).not.toContain( '"${WORKDIR}/resolved-comments.txt"', ); From ad38bcecdb69588b3cb78fdf5b301b3d91241c4e Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Wed, 29 Jul 2026 18:15:30 +0000 Subject: [PATCH 6/6] test(autofix): cover no-op reset branch in timeout census (#8044) --- scripts/tests/qwen-autofix-workflow.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index e885b6951d8..5ee7022710a 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -3244,6 +3244,8 @@ describe('qwen-autofix workflow', () => { '🤖 AutoFix ran out of time before finishing (timeout (3000000ms)) (attempt 2/100) — it will retry on the next scan.'; const PUSH_HEADLINE = '🤖 Addressed the latest review feedback (round 2/100). What changed…'; + const NOOP_HEADLINE = + '🤖 Reviewed the latest feedback — no changes needed. Why, point by point:…'; const mk = (headline, win, at, login = 'qwen-code-dev-bot') => ({ user: { login }, created_at: at, @@ -3285,6 +3287,20 @@ describe('qwen-autofix workflow', () => { K, ), ).toBe('1'); + // A no-op round RESETS the narrowing too: the reset alternation has a + // `no changes needed` branch the push case above never touches, so a + // no-op between two timeouts must also collapse the count to the single + // trailing timeout. + expect( + runCensus( + [ + mk(TIMEOUT_HEADLINE, K, '2026-07-29T04:00:00Z'), + mk(NOOP_HEADLINE, K, '2026-07-29T05:00:00Z'), + mk(TIMEOUT_HEADLINE, K, '2026-07-29T06:00:00Z'), + ], + K, + ), + ).toBe('1'); // Two trailing timeouts count as two. expect( runCensus(