diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index d352701e9c5..34ca7604b7b 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -27,7 +27,10 @@ name: 'Qwen Autofix' # they are the bot's own generated work, trust- # equal to an in-repo bot PR; autofix/skip still # opts them out. -# • issue_comment → '@qwen-code /takeover' (apply the label) and +# • issue_comment → '@qwen-code /takeover' (apply the label), +# '@qwen-code /takeover from N' (apply it and +# seed this window's round counter at N, for a +# PR that already spent N rounds in review), and # '@qwen-code /takeover stop' (remove it) — sugar # for people without label access: the PR author, # or write+ collaborators. Exact-match constants, @@ -125,6 +128,19 @@ env: # only ever bound takeover PRs (the strict cap discards a plain PR at round # 10 before Critical-only could engage), so long-running managed PRs spent # ten rounds growing their diff on suggestions before the brake applied. + # Counted from the window's SEED, not always from zero: '@qwen-code + # /takeover from N' starts the window's counter at N so a PR taken over + # after N rounds of ordinary review reaches this threshold in the + # REMAINDER rather than a fresh five. Without a seed the counter starts at + # 0 exactly as before, so a PR that spent nine human rounds getting to + # "almost mergeable" no longer restarts the suggestion valve at full + # travel the moment it is managed. The seed is window-scoped like every + # other census: '@qwen-code /retry' or a bare re-takeover opens a window + # with no seed and the counter returns to 0 (that IS what re-arming + # means), so a late-stage PR is re-seeded by re-issuing the command with + # its number. It does NOT seed the GROWTH brake below, which anchors its + # baseline at the window's first measured round — a pre-takeover baseline + # is not recoverable, so growth is always measured from engagement. CRITICAL_ONLY_AFTER_ROUND: '5' # Per-author tail budget inside Critical-only mode. An account is an # ACCOUNTABILITY unit, not a throttle: a human login can host an automated @@ -232,7 +248,12 @@ env: TAKEOVER_LABEL: 'autofix/takeover' SKIP_LABEL: 'autofix/skip' # Comment-command sugar over TAKEOVER_LABEL ('' applies it, ' - # stop' removes it). Matched EXACTLY against the trimmed comment body. + # stop' removes it). Matched EXACTLY against the trimmed comment body — + # with ONE parameterized form, ' from N', which applies the label and + # additionally seeds this window's round counter at N (see + # CRITICAL_ONLY_AFTER_ROUND). The literal prefix still has to match this + # constant byte-for-byte; only a bounded 1-2 digit integer is read out of + # the body, and it reaches nothing but an integer comparison. TAKEOVER_COMMAND: '@qwen-code /takeover' # Escalation label: applied when the loop STOPS on a PR (round cap, # consecutive-failure or time-budget breaker) and a human must act — @@ -349,6 +370,7 @@ jobs: ack_pr: '${{ steps.decide.outputs.ack_pr }}' ack_base: '${{ steps.decide.outputs.ack_base }}' takeover_cmd: '${{ steps.decide.outputs.takeover_cmd }}' + takeover_from: '${{ steps.decide.outputs.takeover_from }}' retry_pr: '${{ steps.decide.outputs.retry_pr }}' cmd_pr: '${{ steps.decide.outputs.cmd_pr }}' review_sender: '${{ github.event.review.user.login }}' @@ -388,6 +410,10 @@ jobs: TAKEOVER_ACK='' ACK_BASE='' TAKEOVER_CMD='' + # Round-counter seed carried by ' from N' (see the command + # parser below). Empty on every other path, which reads as "start + # this window at round 0" — the pre-existing behaviour. + TAKEOVER_FROM='' CMD_PR='' RETRY_PR='' DRY_RUN="${DRY_RUN_INPUT:-false}" @@ -508,6 +534,35 @@ jobs: CMD='' [[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND}" ]] && CMD='add' [[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND} stop" ]] && CMD='remove' + # ' from N' — the ONE parameterized form, and the only + # place this workflow reads a value out of a comment body. + # Kept inside the constants discipline above: the literal + # prefix must still match TAKEOVER_COMMAND byte-for-byte, the + # tail is a bounded integer, and the captured value only ever + # reaches an integer comparison and a printf '%s' of a + # re-validated number — never an unquoted shell word, a jq + # program, or an API path. Seeds the round counter so a PR + # that already burned N review rounds before takeover reaches + # CRITICAL_ONLY_AFTER_ROUND after the remainder rather than a + # full fresh five. 1..99: a seed at or past the effective cap + # is clamped at the read sites, but rejecting 3-digit input + # here keeps the obvious typo out entirely. + if [[ "${BODY_TRIMMED}" =~ ^(.*)\ from\ ([0-9]{1,2})$ ]]; then + # Two statements, not one `[[ ... && ... ]]`: BASH_REMATCH is + # only guaranteed populated once the =~ test has completed, + # and reading it from the right-hand operand of the same + # conditional relies on evaluation order this must not bet on. + if [[ "${BASH_REMATCH[1]}" == "${TAKEOVER_COMMAND}" ]]; then + CMD='add' + # 10# canonicalizes the capture to decimal: the value ends + # up in bare-context bash arithmetic downstream (the + # toggle's remainder, the read-site clamps' -ge), where a + # zero-padded spelling is octal — '08'/'09' even error + # outright and drop the seed note. '00' lands on '0', the + # explicit no-seed spelling. + TAKEOVER_FROM="$((10#${BASH_REMATCH[2]}))" + fi + fi # Re-arm shares the takeover command's authorization exactly: # both summon bot activity on a managed PR, so inventing a # second policy would only add surface. @@ -713,6 +768,11 @@ jobs: echo "ack_pr=$(sanitize_number "${PR_NUMBER_EVENT}")" >> "${GITHUB_OUTPUT}" echo "ack_base=${ACK_BASE}" >> "${GITHUB_OUTPUT}" echo "takeover_cmd=${TAKEOVER_CMD}" >> "${GITHUB_OUTPUT}" + # Re-validated on the way out, not just on the way in: this value + # crosses a job boundary into a printf that writes a PR comment, and + # the only shape the marker reader accepts is a bare 1-2 digit + # integer. sanitize_number rejects (and warns about) anything else. + echo "takeover_from=$(sanitize_number "${TAKEOVER_FROM}")" >> "${GITHUB_OUTPUT}" echo "retry_pr=${RETRY_PR}" >> "${GITHUB_OUTPUT}" echo "cmd_pr=${CMD_PR}" >> "${GITHUB_OUTPUT}" echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' pr='#${PR_NUMBER_EVENT:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}" @@ -1853,6 +1913,7 @@ jobs: env: REPO: '${{ github.repository }}' CMD: '${{ needs.route.outputs.takeover_cmd }}' + CMD_FROM: '${{ needs.route.outputs.takeover_from }}' PR: '${{ needs.route.outputs.cmd_pr }}' GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' steps: @@ -1943,6 +2004,40 @@ jobs: esac fi HAS="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")" + # The round seed rides as its OWN marker on a separate line, NEVER as + # a field inside ''. That literal is + # matched with jq `contains()` — closing '-->' included — at seven + # read sites: four here (the ack dedup, the scan's first-pickup + # dedup, and the two REARM_KEY window readers) and three in + # qwen-fleet-shepherd.yml (the paused/resume detector). Appending a + # field would silently break all seven: the window key would fall + # back to an OLDER engage ack, so the round counter would read a dead + # window, and the shepherd would stop seeing the engage as a resume + # signal and age out a PR that was just re-armed. Same reasoning, and + # the same shape, as the autofix-redcheck marker. + # Rendered EN/ZH too, because the ack otherwise reports + # "round 4/100" on its first managed round and reads like a bug. + FROM_MARKER='' + FROM_NOTE='' + FROM_NOTE_ZH='' + FROM_NOTE_REARM='' + FROM_NOTE_REARM_ZH='' + # A seeded RE-ARM must not keep the unseeded fresh-window clause: + # the seed makes the earlier rounds count toward the cap (they ARE + # the seed), and on a re-arm they were typically already-managed + # rounds, not pre-takeover review — both wordings flip below. + REARM_FRESH_CLAUSE=' (previous rounds no longer count toward the cap)' + REARM_FRESH_CLAUSE_ZH='(此前轮次不再计入上限)' + if [[ -n "${CMD_FROM}" && "${CMD_FROM}" =~ ^[0-9]{1,2}$ && "${CMD_FROM}" != '0' ]]; then + FROM_MARKER="$(printf '\n' "${CMD_FROM}")" + REARM_FRESH_CLAUSE=' (earlier rounds count toward the cap only via this seed)' + REARM_FRESH_CLAUSE_ZH='(此前轮次仅通过该种子计入上限)' + FROM_REMAIN="$(( CRITICAL_ONLY_AFTER_ROUND > CMD_FROM ? CRITICAL_ONLY_AFTER_ROUND - CMD_FROM : 0 ))" + FROM_NOTE="$(printf ' This window'"'"'s round counter starts at %s (the rounds this PR spent in review before takeover), so the Critical-only brake engages after %s more change-producing round(s) instead of a full fresh %s.' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + FROM_NOTE_ZH="$(printf '本窗口轮次计数从 %s 起算(即本 PR 托管前已进行的评审轮数),因此再经过 %s 个产生改动的轮次即进入 Critical-only,而非重新计满 %s 轮。' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + FROM_NOTE_REARM="$(printf ' This window'"'"'s round counter restarts at %s (rounds already spent on this PR), so the Critical-only brake engages after %s more change-producing round(s) instead of a full fresh %s.' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + FROM_NOTE_REARM_ZH="$(printf '本窗口轮次计数从 %s 重启(即本 PR 已消耗的轮次),因此再经过 %s 个产生改动的轮次即进入 Critical-only,而非重新计满 %s 轮。' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + fi if [[ "${CMD}" == 'add' ]]; then if [[ "${HAS}" == 'true' ]]; then # Already managed: repeating the command is the ROUND-COUNTER @@ -1951,7 +2046,18 @@ jobs: # a PR that exhausted its rounds continues under management — # no label churn needed. The watermark is untouched: feedback # already addressed is never replayed. - gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🔄 Takeover re-armed: the round counter starts a fresh window (previous rounds no longer count toward the cap); management continues.\n\n
\n中文说明\n\n🔄 已重新武装:轮次计数开启新窗口(此前轮次不再计入上限),托管继续。\n\n
\n\n')" + # Body built ONCE so the retry posts byte-identical text. + # Same one-retry shape as the engage post below — the seed + # marker's only copy rides in this body too — but the final + # fallback is LOUD: nothing heals a missing re-arm (the scan + # heals only engage-less PRs, and the pre-existing engage ack + # suppresses the dedup), and a 're-armed' claim plus the + # stale-escalation cleanup must not follow a window reset that + # never landed (R7-7). + REARM_BODY="$(printf '🔄 Takeover re-armed: the round counter starts a fresh window%s; management continues.%s\n\n
\n中文说明\n\n🔄 已重新武装:轮次计数开启新窗口%s,托管继续。%s\n\n
\n\n%s' "${REARM_FRESH_CLAUSE}" "${FROM_NOTE_REARM}" "${REARM_FRESH_CLAUSE_ZH}" "${FROM_NOTE_REARM_ZH}" "${FROM_MARKER}")" + gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}" \ + || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}"; } \ + || { echo "::error::re-arm ack comment failed on #${PR} after one retry — the round window was NOT reset and no seed landed; re-run the command"; exit 1; } echo "🔄 re-armed ${TAKEOVER_LABEL} window on #${PR}" # Management resumed — the escalation label is stale. 404 is # the common case (the PR was never paused). @@ -1993,7 +2099,14 @@ jobs: FORK_NOTE=' This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes).' FORK_NOTE_ZH='本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。' fi - gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s Remove the `%s` label (or comment `%s stop`) to release.\n\n
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n' "${FORK_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" \ + # Body built ONCE so the retry posts byte-identical text. + # One retry before the heal-path warning: the seed marker's + # only copy lives in this body, and the heal ack has no slot + # to recover it — a transient 5xx must not silently un-seed + # the window. + ENGAGE_BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s%s Remove the `%s` label (or comment `%s stop`) to release.\n\n
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n%s' "${FORK_NOTE}" "${FROM_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${FROM_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FROM_MARKER}")" + gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}" \ + || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}"; } \ || echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it" # Engaged (possibly re-engaging an auto-released PR) — the # escalation label is stale. 404 is the common case. @@ -3229,7 +3342,39 @@ jobs: | select(((.body // "") | contains("")) or ((.body // "") | contains(""))) | .created_at ] | max // "none"' "${WORKDIR}/ic.json")" - ROUND="$(jq -r --arg key "${REARM_KEY}" 'map(select(.win == $key)) | map(.round) | max // 0' <<< "${MARKERS}")" + # Seed for THIS window, from the ' from N' marker carried by + # the comment that IS the window key — so it is window-scoped for + # free, exactly like the key itself: a later /retry or a bare + # /takeover opens a window whose anchor has no marker and the seed + # returns to 0. Read by created_at equality against REARM_KEY, so a + # seed from a SUPERSEDED window can never leak into the live one. + # `scan` (not `capture`, which errors when absent) and `last` + # (a hand-written marker further down a bot comment loses to the + # workflow's own, which is always the final line). + ROUND_START="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${REARM_KEY}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.created_at // "") == $key) + | ((.body // "") | [ scan("") ] | .[] | .[0]) ] + | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" + [[ "${ROUND_START}" =~ ^[0-9]{1,2}$ ]] || ROUND_START=0 + # Clamp strictly below the effective cap. The seed must be able to + # bring the Critical-only brake forward; it must NEVER be able to + # park a PR at its round cap on the very round it is taken over + # (which would stop the loop instead of starting it), and a seed + # is honoured on any PR whose window anchor carries the marker — + # including one whose takeover label was later removed, dropping + # EFF_MAX_ROUNDS back to the strict 10. + if [[ "${EFF_MAX_ROUNDS:-0}" -gt 0 && "${ROUND_START}" -ge "${EFF_MAX_ROUNDS}" ]]; then + echo "🔢 #${PR}: round seed ${ROUND_START} clamped to $(( EFF_MAX_ROUNDS - 1 )) (effective cap ${EFF_MAX_ROUNDS})" + ROUND_START=$(( EFF_MAX_ROUNDS - 1 )) + fi + ROUND="$(jq -r --arg key "${REARM_KEY}" --argjson start "${ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${MARKERS}")" + # No mention of the Critical-only threshold here, deliberately: the + # scan must stay ignorant of that brake. It keeps SELECTING fresh + # suggestions so a no-op report can still advance the watermark; + # only prepare hides them from the agent. A test pins the scan + # against the string. + [[ "${ROUND_START}" != '0' ]] && echo "🔢 #${PR}: window seeded at round ${ROUND_START} → effective round ${ROUND}/${EFF_MAX_ROUNDS}" # Effective watermark = what the agent has actually evaluated (its last # eval marker's newest-feedback timestamp), NOT the last push. A bot @@ -4636,7 +4781,24 @@ jobs: | select(((.body // "") | contains("")) or ((.body // "") | contains(""))) | .created_at ] | max // "none"' "${WORKDIR}/ic.json")" - LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" 'map(select(.win == $key)) | map(.round) | max // 0' <<< "${LIVE_MARKS}")" + # …and so does the round seed: same marker, same created_at-equality + # read against the live window key, same clamp. MAX_ROUNDS is the + # matrix-shadowed EFFECTIVE cap here (see the address job's env), so + # the clamp is against the same ceiling the scan used. + LIVE_ROUND_START="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.created_at // "") == $key) + | ((.body // "") | [ scan("") ] | .[] | .[0]) ] + | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" + [[ "${LIVE_ROUND_START}" =~ ^[0-9]{1,2}$ ]] || LIVE_ROUND_START=0 + # The PRE-clamp value is what the maintainer typed; the Critical-only + # audit clause cites it so a clamped seed never renders a command + # nobody sent while the engage ack still shows the original number. + LIVE_ROUND_START_RAW="${LIVE_ROUND_START}" + if [[ "${MAX_ROUNDS:-0}" -gt 0 && "${LIVE_ROUND_START}" -ge "${MAX_ROUNDS}" ]]; then + LIVE_ROUND_START=$(( MAX_ROUNDS - 1 )) + fi + LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" --argjson start "${LIVE_ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${LIVE_MARKS}")" # The head a sibling last judged, mirrored from the scan's RED_HEAD # parse. A no-op sibling records this marker while leaving BOTH ts and # round UNCHANGED — so the watermark/round triggers below never fire, @@ -5042,6 +5204,7 @@ jobs: fi echo "stale=${STALE}" >> "${GITHUB_OUTPUT}" echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}" + echo "round_start=${LIVE_ROUND_START}" >> "${GITHUB_OUTPUT}" rm -f "${WORKDIR}/deferred-feedback.md" if [[ "${CRITICAL_ONLY}" == "true" ]]; then @@ -5054,8 +5217,29 @@ jobs: # the cause line reads like a misfire to exactly its audience. GROWTH_CLAUSE_EN="the PR's diff grew src ${GROWTH_SRC} / test ${GROWTH_TEST} net lines beyond this counting window's baseline (budgets: ${GROWTH_BUDGET_SRC_LINES}/${GROWTH_BUDGET_TEST_LINES})" GROWTH_CLAUSE_ZH="本计数窗口内 diff 净增长已达 源码 ${GROWTH_SRC} / 测试 ${GROWTH_TEST} 行(预算 ${GROWTH_BUDGET_SRC_LINES}/${GROWTH_BUDGET_TEST_LINES})" + # Name the seed when there is one. Without this the audit record + # claims "5 change-producing rounds are complete" on a PR the loop + # has run twice — true of the counter, visibly false of the PR, and + # unfalsifiable for the maintainer reading it. ROUNDS_CLAUSE_EN="${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds are complete" ROUNDS_CLAUSE_ZH="已完成 ${CRITICAL_ONLY_AFTER_ROUND} 个产生改动的轮次" + # :-0 is load-bearing, not defensive habit: an UNSET seed is not + # '0' under string comparison, so a bare `!= '0'` renders the + # seeded wording on every ordinary PR. + if [[ "${LIVE_ROUND_START:-0}" != '0' ]]; then + # Cite the seed as TYPED, not as clamped: when the read-site + # clamp fired, quoting the clamped value renders a command + # nobody sent while the engage ack still shows the original. + SEED_AS_TYPED="${LIVE_ROUND_START_RAW:-${LIVE_ROUND_START}}" + CLAMP_NOTE_EN='' + CLAMP_NOTE_ZH='' + if [[ "${SEED_AS_TYPED}" != "${LIVE_ROUND_START}" ]]; then + CLAMP_NOTE_EN=", clamped to ${LIVE_ROUND_START} under the effective cap ${MAX_ROUNDS}" + CLAMP_NOTE_ZH=",已按有效上限 ${MAX_ROUNDS} 收敛为 ${LIVE_ROUND_START}" + fi + ROUNDS_CLAUSE_EN="the round counter reached ${CRITICAL_ONLY_AFTER_ROUND} (this window was seeded at round ${SEED_AS_TYPED} by \`${TAKEOVER_COMMAND} from ${SEED_AS_TYPED}\`${CLAMP_NOTE_EN}, plus $(( ROUND - LIVE_ROUND_START )) change-producing round(s) since)" + ROUNDS_CLAUSE_ZH="轮次计数已达 ${CRITICAL_ONLY_AFTER_ROUND}(本窗口由 \`${TAKEOVER_COMMAND} from ${SEED_AS_TYPED}\` 从第 ${SEED_AS_TYPED} 轮起算${CLAMP_NOTE_ZH},此后又完成 $(( ROUND - LIVE_ROUND_START )) 个产生改动的轮次)" + fi if [[ "${CRITICAL_ONLY_ROUNDS}" == 'true' && "${CRITICAL_ONLY_GROWTH}" == 'true' ]]; then CAUSE_EN="${ROUNDS_CLAUSE_EN} and ${GROWTH_CLAUSE_EN}" CAUSE_ZH="${ROUNDS_CLAUSE_ZH},且${GROWTH_CLAUSE_ZH}" @@ -5812,6 +5996,10 @@ jobs: CONFLICT: '${{ steps.prepare.outputs.conflict }}' NEWEST: '${{ steps.prepare.outputs.newest }}' EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' + # The seed the window opened at (prepare's clamped read; 0 when + # unseeded): the milestone crossing trigger counts rounds + # accumulated in the window, not seed-inflated absolute ones. + ROUND_START: '${{ steps.prepare.outputs.round_start }}' # Surfaced in the report footer for diagnosis + attribution; a repo # variable (not a secret), already the agent's OPENAI_MODEL. MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' @@ -6392,12 +6580,16 @@ jobs: # would skip an exact %10 check forever — and a failure-heavy # PR is the very PR the digest exists for. Post on the first # PUSHED round once 10+ rounds have accumulated since the last - # digest in THIS window (or since the window opened). - MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' + # digest in THIS window (or since the window opened). The + # window opens at the round SEED, not at zero: a '/takeover + # from 60' counter starts at 60, so the no-digest-yet baseline + # is the seed — otherwise the seed-inflated counter digests on + # the window's first push with a 1-2 round census. + MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" --argjson start "${ROUND_START:-0}" ' [ .[] | select((.user.login // "") == $ab) | (.body // "") | [ scan("") ] | .[] | select(.[1] == $win) | (.[0] | tonumber) ] - | max // 0' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)" + | max // $start' "${WORKDIR}/ic.json" 2> /dev/null || echo "${ROUND_START:-0}")" if [[ "$(( NEXT_ROUND - MS_LAST ))" -ge 10 ]]; then WIN_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" ' [.[] | select((.user.login // "") == $ab) diff --git a/.qwen/skills/autofix/SKILL.md b/.qwen/skills/autofix/SKILL.md index af441e4cfc0..72d6bcaccbf 100644 --- a/.qwen/skills/autofix/SKILL.md +++ b/.qwen/skills/autofix/SKILL.md @@ -329,10 +329,14 @@ silently overriding or silently complying. drop one silently. - Critical-only mode: when `feedback.md` contains a `Deferred non-Critical feedback` section, the workflow's deterministic brake - has engaged — the PR has completed five suggestion-capable, change-producing - rounds, or its diff has grown past the counting window's net-growth budget - (source and test lines are budgeted separately; the section's preamble names - the cause). That section is an audit record, + has engaged — the window's round counter has reached five, or its diff has + grown past the counting window's net-growth budget (source and test lines are + budgeted separately; the section's preamble names the cause). The counter is + not always the count of rounds YOU have run: a maintainer taking over a PR + that already spent N rounds in ordinary review can seed the window at N + (`@qwen-code /takeover from N`), so the brake can engage on your second or + third round. The preamble says so when it applies; treat it exactly the same + either way. That section is an audit record, not work: do not modify code, resolve threads, or write comment replies for those items. Everything rendered in the actionable sections IS in scope — the deterministic filter defers the automated reviewer's non-Critical diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index a7b150d6dfd..22f9e8b3e2c 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -535,8 +535,10 @@ describe('qwen-autofix workflow', () => { // caught, not just a removed constant. expect(reviewScanJob).toContain('.startedAt // $cut) > $cut'); // Round is the max across markers so a terminal handoff marker is honored - // regardless of its timestamp. - expect(reviewScanJob).toContain('map(.round) | max // 0'); + // regardless of its timestamp; the fallback is the window's SEED (0 unless + // '@qwen-code /takeover from N' anchored this window at N), never a + // hardcoded 0. + expect(reviewScanJob).toContain('map(.round) | max // $start'); // Never fall back to the mutable head commit date for the pre-first-eval // floor (a base-sync HEAD would recreate feedback burial); use the immutable // createdAt, or an empty floor if the metadata query failed. @@ -2997,7 +2999,7 @@ describe('qwen-autofix workflow', () => { )?.[1]; expect(markersProgram).toBeTruthy(); const roundProgram = reviewScanJob.match( - /ROUND="\$\(jq -r --arg key "\$\{REARM_KEY\}" '([\s\S]*?)' <<< "\$\{MARKERS\}"\)"/, + /ROUND="\$\(jq -r --arg key "\$\{REARM_KEY\}" --argjson start "\$\{ROUND_START\}" '([\s\S]*?)' <<< "\$\{MARKERS\}"\)"/, )?.[1]; expect(roundProgram).toBeTruthy(); const pageOne = JSON.stringify([ @@ -3032,7 +3034,7 @@ describe('qwen-autofix workflow', () => { expect(markers.split('\n')).toHaveLength(1); const round = execFileSync( 'jq', - ['-r', '--arg', 'key', 'none', roundProgram], + ['-r', '--arg', 'key', 'none', '--argjson', 'start', '0', roundProgram], { encoding: 'utf8', input: markers }, ).trim(); expect(round).toBe('7'); // max round crosses the page boundary @@ -3770,6 +3772,9 @@ describe('qwen-autofix workflow', () => { author = 'fork-owner', postFails = '', deleteFails = '', + cmdFrom = '', + commentFails = '', + commentFailTimes = '', }) => { const dir = mkdtempSync(join(tmpdir(), 'autofix-toggle-')); try { @@ -3795,7 +3800,7 @@ describe('qwen-autofix workflow', () => { // universally exiting 0. `elif [[ "$1" == "label" && "$2" == "create" ]]; then echo "LABEL-CREATE $*" >> '${join(dir, 'writes.log')}';`, `elif [[ "$1" == "api" ]]; then echo "API $*" >> '${join(dir, 'writes.log')}'; if [[ "$2" == "-X" && "$3" == "POST" && -n "\${TOGGLE_POST_FAILS:-}" ]]; then printf '%s' "\${TOGGLE_POST_FAILS}" >&2; exit 1; fi; if [[ "$2" == "-X" && "$3" == "DELETE" && -n "\${TOGGLE_DELETE_FAILS:-}" ]]; then printf '%s' "\${TOGGLE_DELETE_FAILS}" >&2; exit 1; fi; if [[ "$2" == "-X" && "$3" == "DELETE" ]]; then printf '%s' '[{"name":"Tracks HTTP 5xx flakes"}]'; fi`, - `elif [[ "$1" == "pr" && "$2" == "comment" ]]; then echo "COMMENT $*" >> '${join(dir, 'writes.log')}';`, + `elif [[ "$1" == "pr" && "$2" == "comment" ]]; then echo "COMMENT-ATTEMPT" >> '${join(dir, 'writes.log')}'; if [[ -n "\${TOGGLE_COMMENT_FAILS:-}" ]]; then CF=0; if [[ -f '${join(dir, 'comment-fails')}' ]]; then CF="$(< '${join(dir, 'comment-fails')}')"; fi; if (( CF < \${TOGGLE_COMMENT_FAIL_TIMES:-1} )); then printf '%s' "$(( CF + 1 ))" > '${join(dir, 'comment-fails')}'; printf '%s' "\${TOGGLE_COMMENT_FAILS}" >&2; exit 1; fi; fi; echo "COMMENT $*" >> '${join(dir, 'writes.log')}';`, 'fi', ].join('\n'), ); @@ -3811,7 +3816,7 @@ describe('qwen-autofix workflow', () => { '-eo', 'pipefail', '-c', - `${toggle.replace(/\n {10}/g, '\n')}\nprintf 'DONE'`, + `sleep() { :; }\n${toggle.replace(/\n {10}/g, '\n')}\nprintf 'DONE'`, ], { env: { @@ -3828,6 +3833,10 @@ describe('qwen-autofix workflow', () => { GITHUB_TOKEN: 'x', TOGGLE_POST_FAILS: postFails, TOGGLE_DELETE_FAILS: deleteFails, + TOGGLE_COMMENT_FAILS: commentFails, + TOGGLE_COMMENT_FAIL_TIMES: commentFailTimes, + CMD_FROM: cmdFrom, + CRITICAL_ONLY_AFTER_ROUND: '5', }, encoding: 'utf8', }, @@ -3844,6 +3853,7 @@ describe('qwen-autofix workflow', () => { error.writes = existsSync(join(dir, 'writes.log')) ? readFileSync(join(dir, 'writes.log'), 'utf8') : ''; + error.log = error.stdout ?? ''; throw error; } finally { rmSync(dir, { recursive: true, force: true }); @@ -3891,6 +3901,58 @@ describe('qwen-autofix workflow', () => { ); expect(rearm.writes.match(/^API /gm) ?? []).toHaveLength(1); expect(rearm.log).toContain('re-armed'); + // The seed marker's WRITE side: a valid 'from N' appends the marker on + // its own line after the untouched engage literal on BOTH ack paths, so + // the seed reads see exactly what the parser captured. + const seededEngage = runToggle({ cmd: 'add', cmdFrom: '3' }); + expect(seededEngage.writes).toContain( + '\n', + ); + // Engage wording stays before-takeover (there it is true), and the + // remainder arithmetic reads the harness's CRITICAL_ONLY_AFTER_ROUND=5. + expect(seededEngage.writes).toContain( + 'the rounds this PR spent in review before takeover', + ); + expect(seededEngage.writes).toContain( + 'engages after 2 more change-producing round(s) instead of a full fresh 5', + ); + // A seeded RE-ARM must not contradict itself: the earlier rounds DO + // count toward the cap (they are the seed), so the unseeded "previous + // rounds no longer count" clause cannot ship next to the seed note, and + // the note names rounds already spent on the PR, not pre-takeover + // review (from 60 leaves zero remainder before the brake). + const seededRearm = runToggle({ + cmd: 'add', + labels: ['autofix/takeover'], + cmdFrom: '60', + }); + expect(seededRearm.writes).toContain( + '\n', + ); + expect(seededRearm.writes).toContain( + 'earlier rounds count toward the cap only via this seed', + ); + expect(seededRearm.writes).not.toContain( + 'previous rounds no longer count toward the cap', + ); + expect(seededRearm.writes).toContain('rounds already spent on this PR'); + expect(seededRearm.writes).not.toContain( + 'the rounds this PR spent in review before takeover', + ); + expect(seededRearm.writes).toContain( + 'engages after 0 more change-producing round(s) instead of a full fresh 5', + ); + // The unseeded re-arm keeps the original fresh-window clause… + expect(rearm.writes).toContain( + 'previous rounds no longer count toward the cap', + ); + // …and no path emits a marker for the guard values: the explicit + // no-seed spelling '0', empty, or a non-number. + for (const from of ['', '0', 'abc']) { + expect(runToggle({ cmd: 'add', cmdFrom: from }).writes).not.toContain( + 'autofix-round-start', + ); + } // remove + present → label removed, through the URI-encoded path segment // (real jq runs in the substitution, so the %2F is the executed truth). const removePresent = runToggle({ @@ -4018,6 +4080,73 @@ describe('qwen-autofix workflow', () => { } expect(engageFailure).toBeTruthy(); expect(engageFailure.writes).not.toContain('takeover-ack engaged'); + // A TRANSIENT engage-ack failure retries once before the heal-path + // warning: the seed marker's only copy lives in the failed body, and + // the heal ack has no slot to recover it — the retry is the only thing + // that keeps a 'from N' seed alive across a 5xx. + const transientAckFailure = runToggle({ + cmd: 'add', + cmdFrom: '12', + commentFails: 'HTTP 502', + }); + expect(transientAckFailure.done).toBe(true); + expect( + transientAckFailure.writes.match(/^COMMENT-ATTEMPT$/gm) ?? [], + ).toHaveLength(2); + expect(transientAckFailure.writes).toContain( + '\n', + ); + expect(transientAckFailure.log).not.toContain('::warning::'); + // A TRANSIENT re-arm ack failure gets the same one-retry shape — the + // seed marker's only copy rides in the re-arm body too, and nothing + // heals a missing re-arm (the scan heals only engage-less PRs, and the + // pre-existing engage ack suppresses the dedup), so a single 5xx must + // not drop the window reset AND the seed. The fallback stays loud + // (there is no heal path to warn-and-lean on): a double failure aborts + // before the 're-armed' claim and the stale-escalation cleanup. + const transientRearmFailure = runToggle({ + cmd: 'add', + labels: ['autofix/takeover'], + cmdFrom: '7', + commentFails: 'HTTP 502', + }); + expect(transientRearmFailure.done).toBe(true); + expect( + transientRearmFailure.writes.match(/^COMMENT-ATTEMPT$/gm) ?? [], + ).toHaveLength(2); + expect(transientRearmFailure.writes).toContain( + '\n', + ); + expect(transientRearmFailure.log).toContain('re-armed'); + expect(transientRearmFailure.log).not.toContain('::error::'); + // …but a DOUBLE re-arm ack failure must abort instead: nothing heals a + // missing re-arm (the scan heals only engage-less PRs, and the + // pre-existing engage ack suppresses the dedup), so the 're-armed' + // claim and the stale-escalation cleanup must never follow a window + // reset that never landed. The stub's fail-once guard above can never + // reach this arm — TOGGLE_COMMENT_FAIL_TIMES=2 makes both POSTs fail. + let rearmDoubleFailure; + try { + runToggle({ + cmd: 'add', + labels: ['autofix/takeover'], + cmdFrom: '7', + commentFails: 'HTTP 502', + commentFailTimes: 2, + }); + } catch (error) { + rearmDoubleFailure = error; + } + expect(rearmDoubleFailure).toBeTruthy(); + expect( + rearmDoubleFailure.writes.match(/^COMMENT-ATTEMPT$/gm) ?? [], + ).toHaveLength(2); + expect(rearmDoubleFailure.writes).not.toContain('takeover-ack engaged'); + expect(rearmDoubleFailure.writes).not.toContain( + 'labels/autofix%2Fneeds-human', + ); + expect(rearmDoubleFailure.log).toContain('::error::'); + expect(rearmDoubleFailure.log).not.toContain('re-armed'); // The release DELETE tolerates the 404 race (a concurrent removal // already reached the end state): the release ack still posts, no // warning. @@ -4201,6 +4330,136 @@ describe('qwen-autofix workflow', () => { expect(prepareBranchAndFeedbackStep).toContain('LIVE_REARM_KEY'); }); + it('behaviorally seeds the round counter from the window anchor and only from it', () => { + // '@qwen-code /takeover from N' rides as its OWN marker on the engage + // ack, never as a field inside '' — that + // literal is matched with jq contains(), closing '-->' included, at four + // sites here and three in qwen-fleet-shepherd.yml, and a field would + // silently break all seven. Replay the scan's real trio to prove the + // marker survives alongside the untouched ack, and that the seed is + // scoped exactly like the window key it is read from. + const trio = reviewScanJob.match( + /(MARKERS="\$\(jq -c[\s\S]*?ROUND="\$\(jq -r --arg key "\$\{REARM_KEY\}"[^\n]*)/, + )?.[1]; + expect(trio).toBeTruthy(); + const BOT = 'qwen-code-dev-bot'; + const roundOf = (comments) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-seed-')); + try { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); + return execFileSync( + 'bash', + [ + '-c', + `set -uo pipefail\nWORKDIR='${dir}'\n${trio.replace(/\n {12}/g, '\n')}\nprintf '\\n%s' "$ROUND"`, + ], + { + // EFF_MAX_ROUNDS is SET so both clamp branches execute in the + // fixtures below; PR is the clamp echo's only other expansion + // under set -u. + env: { + ...process.env, + AUTOFIX_BOT: BOT, + EFF_MAX_ROUNDS: '10', + PR: '1', + }, + encoding: 'utf8', + }, + ) + .split('\n') + .at(-1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const ack = (at, seed) => ({ + user: { login: BOT }, + created_at: at, + body: `🤝 …\n${seed === undefined ? '' : `\n`}`, + }); + const evalMarker = (at, round, win) => ({ + user: { login: BOT }, + created_at: at, + body: ``, + }); + const K1 = '2026-08-03T00:00:00Z'; + const K0 = '2026-08-01T00:00:00Z'; + // The whole point: a PR taken over at round 3 starts there, so with + // CRITICAL_ONLY_AFTER_ROUND=5 the brake is two managed rounds away + // instead of five. + expect(roundOf([ack(K1, 3)])).toBe('3'); + // Last marker wins: a hand-written marker prepended by an edit loses to + // the workflow's own final-line marker (GitHub edits preserve user.login + // and created_at, so the edited ack still passes both filter halves). + expect( + roundOf([ + { + user: { login: BOT }, + created_at: K1, + body: 'edited \n\n', + }, + ]), + ).toBe('3'); + // Real markers outrank the seed as soon as one exists — the seed is a + // FLOOR for an empty window, not an offset added to every round. + expect( + roundOf([ack(K1, 3), evalMarker('2026-08-04T00:00:00Z', 4, K1)]), + ).toBe('4'); + // Window scoping, in both directions. A superseded window's seed cannot + // leak forward… + expect(roundOf([ack(K0, 7), ack(K1, 3)])).toBe('3'); + // …and a bare re-arm (no marker) drops the seed to 0, which is what + // per-window scoping MEANS: re-arming a late-stage PR reopens the + // suggestion valve unless the number is supplied again. + expect(roundOf([ack(K0, 7), ack(K1)])).toBe('0'); + // Unseeded acks behave exactly as before this feature existed. + expect(roundOf([ack(K1)])).toBe('0'); + expect(roundOf([])).toBe('0'); + // Trust boundary: the seed is read from the bot-authored comment whose + // created_at IS the window key. Both halves of that predicate carry + // weight, so both are exercised against a payload that would otherwise + // park the PR at its round cap. The stranger here posts AT the window + // key — GitHub stamps created_at to the second, so a comment landing in + // the same second as the engage ack is a real collision and the author + // filter is the only thing that rejects it. (Dating the impostor + // anywhere else tests the key check twice and the author check not at + // all: with the author filter deleted such a case still passes.) + expect( + roundOf([ + ack(K1), + { + user: { login: 'mallory' }, + created_at: K1, + body: 'lgtm ', + }, + ]), + ).toBe('0'); + expect( + roundOf([ + ack(K1), + { + user: { login: BOT }, + created_at: '2026-08-05T00:00:00Z', + body: 'report ', + }, + ]), + ).toBe('0'); + // Shape gate: the marker reader takes 1-2 digits, so a longer number is + // not silently truncated to its first two digits. + expect(roundOf([ack(K1, 100)])).toBe('0'); + // Read-site clamp (cap 10 from the harness env): a seed at or past the + // cap lands strictly below it… + expect(roundOf([ack(K1, 15)])).toBe('9'); + // …and a seed just under the cap passes through unclamped. Same value + // out, different path — together they pin both clamp branches against + // deletion and against a -ge→-le flip (which would clamp every seeded + // window to cap−1). + expect(roundOf([ack(K1, 9)])).toBe('9'); + // The ack literal the other seven read sites match must survive verbatim + // next to the seed marker. + expect(ack(K1, 3).body).toContain(''); + }); + it('behaviorally validates forced targets against author, takeover, and skip', () => { // Extract the forced-PR classifier VERBATIM and replay it: the bot's // own PRs pass; a human PR passes only with the takeover label; skip @@ -5124,7 +5383,7 @@ exit 1 'bash', [ '-c', - `${sanitize.replace(/\n {10}/g, '\n')}\nEVENT_NAME=issue_comment\nTAKEOVER_CMD=''\nCMD_PR=''\n${cmdBranch.replace(/\n {14}/g, '\n')}\nprintf '%s|%s' "$TAKEOVER_CMD" "$CMD_PR"`, + `${sanitize.replace(/\n {10}/g, '\n')}\nEVENT_NAME=issue_comment\nTAKEOVER_CMD=''\nTAKEOVER_FROM=''\nCMD_PR=''\n${cmdBranch.replace(/\n {14}/g, '\n')}\nprintf '%s|%s' "$TAKEOVER_CMD" "$CMD_PR"`, ], { env: { @@ -5233,6 +5492,119 @@ exit 1 ).toBe('add|7165'); }); + it('behaviorally parses the takeover round seed and keeps every other body closed', () => { + // '@qwen-code /takeover from N' is the ONE parameterized command form, so + // it is also the one place a value is read out of a comment body. Replay + // the issue_comment branch VERBATIM (drift fails) and pin both halves: + // the literal prefix must still match TAKEOVER_COMMAND byte-for-byte, and + // the tail must be a bounded integer. Everything else — a prefixed body, a + // 'stop from N' hybrid, double spaces, a 3-digit number, a shell/command + // substitution payload — must fail CLOSED to "not an exact command", which + // is the property the constants-only discipline bought in the first place. + const sanitize = routeStep.match( + /(sanitize_number\(\) \{[\s\S]*?\n {10}\})/, + )?.[1]; + const cmdBranch = routeStep.match( + /(if \[\[ "\$\{EVENT_NAME\}" == 'issue_comment' \]\]; then[\s\S]*?\n {14}fi)/, + )?.[1]; + expect(sanitize).toBeTruthy(); + expect(cmdBranch).toBeTruthy(); + const seedOf = (body) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-from-')); + try { + writeFileSync( + join(dir, 'gh'), + `#!/bin/bash\nif [[ "$*" == *"/pulls/"* ]]; then printf '%s' 'QwenLM/qwen-code'; else printf '%s' 'write'; fi\n`, + ); + chmodSync(join(dir, 'gh'), 0o755); + // TAKEOVER_FROM is echoed through sanitize_number exactly as the + // route step's own output line does, so a value that survives the + // parser but not the re-validation shows up here as empty. + const out = execFileSync( + 'bash', + [ + '-c', + `${sanitize.replace(/\n {10}/g, '\n')}\nEVENT_NAME=issue_comment\nTAKEOVER_CMD=''\nTAKEOVER_FROM=''\nCMD_PR=''\n${cmdBranch.replace(/\n {14}/g, '\n')}\nprintf '%s|%s' "$TAKEOVER_CMD" "$(sanitize_number "$TAKEOVER_FROM")"`, + ], + { + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + COMMENT_BODY: body, + SENDER_LOGIN: 'maintainer-b', + COMMENT_PR_AUTHOR: 'human-a', + HAS_PR_URL: 'url', + ISSUE_STATE: 'open', + ISSUE_NUMBER: '7165', + AUTOFIX_BOT: 'qwen-code-dev-bot', + TAKEOVER_COMMAND: '@qwen-code /takeover', + TAKEOVER_LABEL: 'autofix/takeover', + REPO: 'QwenLM/qwen-code', + GITHUB_TOKEN: 'x', + }, + encoding: 'utf8', + }, + ).split('\n'); + return out.at(-1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + // Seeded engagement, including surrounding whitespace (the body is + // trimmed before matching) and the two-digit upper end. + expect(seedOf('@qwen-code /takeover from 3')).toBe('add|3'); + expect(seedOf(' @qwen-code /takeover from 12 ')).toBe('add|12'); + expect(seedOf('@qwen-code /takeover from 99')).toBe('add|99'); + // 'from 0' is the explicit no-seed spelling: it must engage exactly like + // the bare command rather than being rejected, so a maintainer who types + // it gets management, not silence. + expect(seedOf('@qwen-code /takeover from 0')).toBe('add|0'); + // Zero-padded spellings canonicalize to decimal at capture: the seed + // reaches bare-context bash arithmetic downstream, where a leading zero + // means octal — '08'/'09' error outright and silently drop the seed + // note — and '00' lands on the explicit no-seed spelling '0'. + expect(seedOf('@qwen-code /takeover from 08')).toBe('add|8'); + expect(seedOf('@qwen-code /takeover from 01')).toBe('add|1'); + expect(seedOf('@qwen-code /takeover from 00')).toBe('add|0'); + // The unparameterized forms are untouched. + expect(seedOf('@qwen-code /takeover')).toBe('add|'); + expect(seedOf('@qwen-code /takeover stop')).toBe('remove|'); + // Fail-closed set. 'stop from 3' is the interesting one: it must NOT + // release (the exact-'stop' match misses) and must NOT engage (the + // prefix is not TAKEOVER_COMMAND) — an ambiguous body does nothing. + for (const body of [ + '@qwen-code /takeover stop from 3', + '@qwen-code /takeover from 100', + '@qwen-code /takeover from 3x', + '@qwen-code /takeover from -1', + '@qwen-code /takeover from', + '@qwen-code /takeover from 3', + '@qwen-code /takeoverfrom 3', + 'please @qwen-code /takeover from 3', + '@qwen-code /takeover from 3 please', + '@qwen-code /takeover from 3; rm -rf /', + '@qwen-code /takeover from $(id)', + '@qwen-code /takeover from `id`', + ]) { + expect(seedOf(body)).toBe('|'); + } + // The captured value crosses a GITHUB_OUTPUT write and two + // job-boundary wires no behavioral harness exercises — every replay + // injects CMD_FROM directly, starting inside a single job — so pin + // them verbatim like the suite's other wires: a deleted or typo'd + // link would silently degrade 'from N' to a bare '/takeover' with the + // whole suite still green. + expect(workflow).toContain( + 'echo "takeover_from=$(sanitize_number "${TAKEOVER_FROM}")" >> "${GITHUB_OUTPUT}"', + ); + expect(workflow).toContain( + "takeover_from: '${{ steps.decide.outputs.takeover_from }}'", + ); + expect(workflow).toContain( + "CMD_FROM: '${{ needs.route.outputs.takeover_from }}'", + ); + }); + it('gates real-time review triggers on bot author, trusted sender, and in-repo PR', () => { // Route step must check PR author against AUTOFIX_BOT for review events // (an in-repo PR is managed only when the bot authored it). @@ -7671,12 +8043,12 @@ exit 1 /(GROWTH_CLAUSE_EN="the PR's diff grew[\s\S]*?CAUSE_ZH="\$\{ROUNDS_CLAUSE_ZH\}"\n\s+fi)/, )?.[1]; expect(causeBlock).toBeTruthy(); - const causeFor = (rounds, growth, growthSrc, growthTest) => + const causeFor = (rounds, growth, growthSrc, growthTest, seed = '') => execFileSync( 'bash', [ '-c', - `CRITICAL_ONLY_ROUNDS=${rounds}\nCRITICAL_ONLY_GROWTH=${growth}\nGROWTH_SRC=${growthSrc}\nGROWTH_TEST=${growthTest}\nGROWTH_BUDGET_SRC_LINES=400\nGROWTH_BUDGET_TEST_LINES=400\nCRITICAL_ONLY_AFTER_ROUND=5\n${causeBlock}\nprintf '%s\\n%s' "$CAUSE_EN" "$CAUSE_ZH"`, + `CRITICAL_ONLY_ROUNDS=${rounds}\nCRITICAL_ONLY_GROWTH=${growth}\nGROWTH_SRC=${growthSrc}\nGROWTH_TEST=${growthTest}\nGROWTH_BUDGET_SRC_LINES=400\nGROWTH_BUDGET_TEST_LINES=400\nCRITICAL_ONLY_AFTER_ROUND=5\nTAKEOVER_COMMAND='@qwen-code /takeover'\n${seed}\n${causeBlock}\nprintf '%s\\n%s' "$CAUSE_EN" "$CAUSE_ZH"`, ], { encoding: 'utf8' }, ).split('\n'); @@ -7694,6 +8066,55 @@ exit 1 expect(both[0]).toContain('rounds are complete and'); expect(both[0]).toContain('src 900 / test 20'); expect(both[1]).toContain('轮次,且'); + // A SEEDED window must not claim five completed rounds: this PR reached + // the threshold from `@qwen-code /takeover from 3` plus two managed + // rounds, and the audit record has to say so or a maintainer reading + // "5 change-producing rounds are complete" on a twice-run PR cannot tell + // the brake from a misfire. An UNSET seed (every ordinary PR, and the + // roundsOnly case above) must keep the plain wording — `!= '0'` without + // the :-0 default renders the seeded text for everyone. + const seeded = causeFor( + 'true', + 'false', + 0, + 0, + 'LIVE_ROUND_START=3\nROUND=5', + ); + expect(seeded[0]).toContain('seeded at round 3'); + expect(seeded[0]).toContain('@qwen-code /takeover from 3'); + expect(seeded[0]).toContain('plus 2 change-producing round(s) since'); + expect(seeded[0]).not.toBe('5 change-producing rounds are complete'); + expect(seeded[1]).toContain('从第 3 轮起算'); + expect(seeded[1]).toContain('又完成 2 个产生改动的轮次'); + // A seed that hit the read-site clamp must still cite the number as + // TYPED: quoting the post-clamp value renders a command nobody sent + // while the engage ack above still shows the original (from 12, + // clamped to 9 under cap 10 — the label-removal path the clamp's own + // comment names). + const clampedSeed = causeFor( + 'true', + 'false', + 0, + 0, + 'LIVE_ROUND_START_RAW=12\nLIVE_ROUND_START=9\nROUND=14\nMAX_ROUNDS=10', + ); + expect(clampedSeed[0]).toContain('seeded at round 12'); + expect(clampedSeed[0]).toContain('@qwen-code /takeover from 12'); + expect(clampedSeed[0]).toContain('clamped to 9 under the effective cap 10'); + expect(clampedSeed[0]).toContain('plus 5 change-producing round(s) since'); + expect(clampedSeed[0]).not.toContain('from 9'); + expect(clampedSeed[1]).toContain('从第 12 轮起算'); + expect(clampedSeed[1]).toContain('收敛为 9'); + // The seed crosses two more job-boundary wires no behavioral harness + // exercises — the cause replay above injects LIVE_ROUND_START and the + // digest replay injects ROUND_START, both starting inside a single job + // — so pin them verbatim like the suite's other wires. + expect(workflow).toContain( + 'echo "round_start=${LIVE_ROUND_START}" >> "${GITHUB_OUTPUT}"', + ); + expect(workflow).toContain( + "ROUND_START: '${{ steps.prepare.outputs.round_start }}'", + ); // The batch-budget sentence must describe the policy actually in force: // the OVER_BUDGET census only builds spans in round-brake territory, so @@ -9987,6 +10408,7 @@ exit 1 outcome = 'fixed', maxRounds = '100', commentExit = 0, + roundStart = '', } = {}, ) => { const dir = mkdtempSync(join(tmpdir(), 'milestone-')); @@ -10021,6 +10443,7 @@ exit 1 PR: '1', TAKEOVER_LABEL: 'autofix/takeover', TAKEOVER_COMMAND: '@qwen-code /takeover', + ROUND_START: roundStart, }, encoding: 'utf8', }, @@ -10108,6 +10531,29 @@ exit 1 { nextRound: 11 }, ); expect(freshWindow.body).toContain('round 11/100'); + // Seeded windows count rounds IN THE WINDOW: the window opens at the + // seed, so "10+ accumulated" is measured from the seed — a takeover + // 'from 60' must not digest on its first managed rounds just because + // the absolute counter already reads 61+… + expect( + runDigest([evalC(HEADS.noop, K, '2026-07-02T00:00:00Z')], { + nextRound: 61, + roundStart: '60', + }).body, + ).toBe(''); + expect( + runDigest([evalC(HEADS.noop, K, '2026-07-02T00:00:00Z')], { + nextRound: 10, + roundStart: '8', + }).body, + ).toBe(''); + // …and once 10 managed rounds HAVE accumulated past the seed, it posts. + expect( + runDigest([evalC(HEADS.push, K, '2026-07-02T00:00:00Z')], { + nextRound: 70, + roundStart: '60', + }).body, + ).toContain('round 70/100'); // WINDOW=none says what it counts instead of claiming a window. const noWindow = runDigest( @@ -14263,7 +14709,7 @@ exit 1 created_at: at, body: ``, }); - const run = (comments) => { + const run = (comments, { maxRounds } = {}) => { const dir = mkdtempSync(join(tmpdir(), 'rearm-live-')); writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); const out = execFileSync( @@ -14273,7 +14719,12 @@ exit 1 `set -uo pipefail\n${block}\nprintf '%s|%s|%s' "$LIVE_EVAL_WM" "$LIVE_REARM_KEY" "$LIVE_MAX_ROUND"`, ], { - env: { ...process.env, WORKDIR: dir, AUTOFIX_BOT: BOT }, + env: { + ...process.env, + WORKDIR: dir, + AUTOFIX_BOT: BOT, + ...(maxRounds === undefined ? {} : { MAX_ROUNDS: maxRounds }), + }, encoding: 'utf8', }, ); @@ -14322,6 +14773,20 @@ exit 1 }, ]); expect(wmSpoof).toBe('2026-07-20T08:30:00Z'); + + // Seeded windows compare too: the LIVE copy honors a marker on the + // window anchor and clamps a seed at/past the cap exactly like the + // scan-side twin (cap 10: 15 clamps to 9; 9 passes through), so the two + // copies cannot drift on a seeded window. + const seededAck = (at, seed) => ({ + user: { login: BOT }, + created_at: at, + body: `🤝 … \n`, + }); + const SEEDED_AT = '2026-07-20T12:00:00Z'; + expect(run([seededAck(SEEDED_AT, 3)], { maxRounds: '10' })[2]).toBe('3'); + expect(run([seededAck(SEEDED_AT, 15)], { maxRounds: '10' })[2]).toBe('9'); + expect(run([seededAck(SEEDED_AT, 9)], { maxRounds: '10' })[2]).toBe('9'); }); it('routes @qwen-code /retry through the takeover command authorization', () => {