diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 99208ab3939..2d528dbf93f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -1744,7 +1744,7 @@ jobs: # (the watermark floor below), and labels (the effective round # cap) — avoids extra round-trips per candidate PR. PR_META="$(gh pr view "${PR}" --repo "${REPO}" \ - --json headRefName,statusCheckRollup,createdAt,labels,isCrossRepository,headRepositoryOwner,headRepository 2> /dev/null || echo '{}')" + --json headRefName,headRefOid,statusCheckRollup,createdAt,labels,isCrossRepository,headRepositoryOwner,headRepository 2> /dev/null || echo '{}')" HEAD_REPO_FULL="${REPO}" if [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" == "true" ]]; then HR_OWNER="$(jq -r '.headRepositoryOwner.login // ""' <<< "${PR_META}")" @@ -1902,6 +1902,16 @@ jobs: [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) | .created_at ] | max // ""' "${WORKDIR}/ic.json")" + # The head this PR's red checks were last reported against. Carried + # as its OWN marker inside the eval comment rather than a new field + # on autofix-eval, so none of the ts/acted/round parsers change — + # and the comment still matches the eval filter, so the agent never + # sees it as feedback. + RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" ' + [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") + | [ scan("") ] | .[] + | {sha: .[0], at: ($c.created_at // "")} ] + | sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")" # A maintainer '@qwen-code /retry' posts that marker to say # "evaluate this feedback again": markers written BEFORE it stop # holding the watermark down. This is the sanctioned exception to @@ -1995,6 +2005,30 @@ jobs: | select((.completedAt // .updatedAt // "") > $wm) ] | length ' <<< "${CHECKS_JSON}")" + # A red check is a persistent STATE, not the instant it turned red. + # Counting only "failed since the watermark" made a still-failing PR + # invisible the moment the watermark passed the failure: measured on + # #6451 (3 reds, all completed 09:30-09:51, watermark 10:55), + # #7357 (red 07:59, watermark 09:18) and #7390 (red and watermark + # both 11:27:37, so a strict `>` hid it the instant it appeared) — + # all three sat red for hours while every scan logged "nothing new". + # + # So: a currently-red check counts as feedback until the head it ran + # against has been evaluated. The address job records the head it + # reported on; a PR whose recorded head still matches is left alone, + # which bounds this to ONE look per head instead of every scan. + # Empty LIVE_HEAD → N_RED_NOW stays 0: fail-closed (no head → cannot judge → do not act), + # unlike the recording side where an empty REPORT_HEAD keeps reds visible. + LIVE_HEAD="$(jq -r '.headRefOid // ""' <<< "${PR_META}")" + N_RED_NOW=0 + if [[ -n "${LIVE_HEAD}" && "${RED_HEAD}" != "${LIVE_HEAD}" ]]; then + N_RED_NOW="$(jq ' + [ .[] + | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) + | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) ] + | length + ' <<< "${CHECKS_JSON}")" + fi gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate > "${WORKDIR}/rv.json" gh api "repos/${REPO}/pulls/${PR}/comments" --paginate > "${WORKDIR}/rc.json" @@ -2049,14 +2083,14 @@ jobs: HAS_CONFLICT='false' if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then HAS_CONFLICT='true'; fi - if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then + if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${N_RED_NOW}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then echo "✅ #${PR}: nothing new since ${EFF_WM} (conflict=${HAS_CONFLICT})" fleet_row "${PR}" 'idle' "nothing new since ${EFF_WM} (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})" continue fi - echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) + ${N_FAILED_CHECKS} failed check(s) new, conflict=${HAS_CONFLICT}, round=${ROUND}" - fleet_row "${PR}" 'SELECTED' "${N_REVIEWS} review + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} comment + ${N_FAILED_CHECKS} failed-check new (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})" + echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) + ${N_FAILED_CHECKS} failed check(s) new, ${N_RED_NOW} still-red on ${LIVE_HEAD:0:9}, conflict=${HAS_CONFLICT}, round=${ROUND}" + fleet_row "${PR}" 'SELECTED' "${N_REVIEWS} review + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} comment + ${N_FAILED_CHECKS} failed-check new, ${N_RED_NOW} still-red (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})" TARGETS="$(jq -c \ --arg pr "${PR}" --arg branch "${BRANCH}" --arg issue "${ISSUE}" \ --arg round "${ROUND}" --arg wm "${EFF_WM}" --arg mr "${EFF_MAX_ROUNDS}" \ @@ -2402,6 +2436,12 @@ jobs: git checkout -B "${BRANCH}" "origin/${BRANCH}" fi + # The exact SHA the agent will evaluate. Captured HERE, before any + # agent mutation, so the marker records what was actually looked at — + # not the report-time remote head, which can move during the run. + CHECKED_OUT_HEAD="$(git rev-parse HEAD)" + echo "checked_out_head=${CHECKED_OUT_HEAD}" >> "${GITHUB_OUTPUT}" + # Does the branch conflict with base? merge-tree computes the merge # without touching the tree; exit 1 means conflicts. UNKNOWN/errors are # treated as no-conflict so we never block on a transient state. @@ -2455,14 +2495,17 @@ jobs: # QUEUES the duplicate rather than discarding it — but that queueing # is exactly what makes this check sound: address jobs for one PR run # strictly one at a time, so by the time the duplicate runs here, the - # first job's eval marker is posted and visible. Two duplicate + # first job's eval marker is posted and visible. Three duplicate # signatures: (a) a sibling evaluated through a NEWER live ts than # our matrix watermark; (b) a conflict-only sibling resolved and # marked at the SAME ts — with no newer feedback its marker keeps # ts=watermark while its ROUND advances past ours (ours is the max - # round observed at scan time). Either way, if there is no live - # conflict left and nothing newer than the live watermark, this run - # is a stale duplicate and discards itself. + # round observed at scan time); (c) a no-op sibling judged THIS exact + # head (its redcheck marker matches CHECKED_OUT_HEAD) while keeping + # BOTH ts and round unchanged — neither (a) nor (b) fires, but + # re-running would post a duplicate report for the same head. Either + # way, if there is no live conflict left and nothing newer than the + # live watermark, this run is a stale duplicate and discards itself. STALE='false' LIVE_MARKS="$(jq -r --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") @@ -2486,6 +2529,16 @@ jobs: 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}")" + # 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, + # yet a second same-watermark target for the same head would otherwise + # run the agent again and post a duplicate report for that head. + LIVE_RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" ' + [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") + | [ scan("") ] | .[] + | {sha: .[0], at: ($c.created_at // "")} ] + | sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")" # A re-arm SUPERSEDES every job selected under the old window key: # the maintainer asked for a fresh start, so a queued old-window job # must discard instead of finishing and stamping an old-sequence @@ -2495,7 +2548,8 @@ jobs: echo "🫥 window superseded: selected under key '${WINDOW:-none}' but the live window is '${LIVE_REARM_KEY}' (re-armed while queued) — discarding without action or marker" fi if [[ -n "${LIVE_EVAL_WM}" && "${CONFLICT}" != "true" ]] \ - && { [[ "${LIVE_EVAL_WM}" > "${WATERMARK}" ]] || [[ "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]]; }; then + && { [[ "${LIVE_EVAL_WM}" > "${WATERMARK}" ]] || [[ "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]] \ + || { [[ -n "${LIVE_RED_HEAD}" ]] && [[ "${LIVE_RED_HEAD}" == "${CHECKED_OUT_HEAD}" ]]; }; }; then LIVE_NEW="$(jq -rs \ --arg wm "${LIVE_EVAL_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \ --argjson trust "${TRUSTED_ASSOC}" ' @@ -2599,6 +2653,20 @@ jobs: | select((.completedAt // .updatedAt // "") > $wm) | "- \(((.name // .workflowName) // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \ "${WORKDIR}/checks.json" + # Checks that turned red BEFORE the watermark and are STILL red. + # The section above only shows checks that failed AFTER the + # watermark, so a persistent red (the exact case the scan's + # N_RED_NOW gate selects) would leave the agent with an empty + # "Failed checks" section and no check name to reproduce. + echo + echo "## Still-red checks (persisting from before the last evaluation)" + jq -r --arg wm "${WATERMARK}" ' + .[] + | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) + | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) + | select((.completedAt // .updatedAt // "") <= $wm) + | "- \(((.name // .workflowName) // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \ + "${WORKDIR}/checks.json" # If the LAST round ended in a gate rejection, show the agent WHY. # It is otherwise invisible on the retry: the reason lives in the # bot's own handoff comment, which the feedback filter above @@ -2905,7 +2973,13 @@ jobs: # 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 }}' + CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' run: |- + # The head the agent actually evaluated — captured in prepare before + # any mutation, not the report-time remote head (which can move + # during the run). Empty when prepare exited early, which matches + # no marker and keeps reds visible — fail-open. + REPORT_HEAD="${CHECKED_OUT_HEAD}" # Prepare may have adopted a sibling's live round; the matrix value # would double-write that round's marker. ROUND="${EFFECTIVE_ROUND:-${ROUND}}" @@ -3011,6 +3085,7 @@ jobs: echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" echo echo "" + echo "" } > "${WORKDIR}/report.md" STATUS="pushed (round ${NEXT_ROUND}/${MAX_ROUNDS})" else @@ -3027,6 +3102,7 @@ jobs: echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" echo echo "" + echo "" } > "${WORKDIR}/report.md" STATUS="no action needed" fi @@ -3061,7 +3137,13 @@ jobs: STALE: '${{ steps.prepare.outputs.stale }}' EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}' + CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}' run: |- + # The head the agent actually evaluated — captured in prepare before + # any mutation, not the report-time remote head (which can move + # during the run). Empty when prepare exited early, which matches + # no marker and keeps reds visible — fail-open. + REPORT_HEAD="${CHECKED_OUT_HEAD}" ROUND="${EFFECTIVE_ROUND:-${ROUND}}" MODEL_DISPLAY="${MODEL:-default}" SUFFIX='' @@ -3313,6 +3395,14 @@ jobs: echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`" echo echo "" + # A sentinel ts means the agent evaluated NOTHING (crash, API + # error, gate crash) and the next scan must retry. Recording a + # judged head here would make RED_HEAD == LIVE_HEAD, so the + # retry scan sees N_RED_NOW=0 and goes idle despite the handoff + # promising a retry. + if [[ "${MARK_TS}" != '9999-12-31T23:59:59Z' ]]; then + echo "" + fi } > "${WORKDIR}/report.md" gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}" fi diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index a773cf395ff..83ff0dc16ca 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -294,9 +294,16 @@ describe('qwen-autofix workflow', () => { expect(reviewScanJob).toContain('.conclusion // .state // ""'); expect(reviewScanJob).toContain('.workflowName // ""'); expect(reviewScanJob).toContain('startswith("review-address")'); + // Every failed-check selector must carry the address-check carve-out, or + // the loop reads its OWN runs as feedback about the PR. Asserting the + // property rather than the count lets a new selector be added, but not + // one that forgets the carve-out. + const scanCheckSelectors = + reviewScanJob.match(/IN\("(?:FAILURE|QUEUED)"/g) ?? []; + expect(scanCheckSelectors.length).toBeGreaterThanOrEqual(3); expect( reviewScanJob.match(/startswith\("review-address"\)/g) ?? [], - ).toHaveLength(2); + ).toHaveLength(scanCheckSelectors.length); expect(reviewScanJob).toContain('"${N_FAILED_CHECKS}" -eq 0'); expect(reviewScanJob).toContain('${N_FAILED_CHECKS} failed check(s) new'); expect(reviewScanJob).toContain('.completedAt // .updatedAt // ""'); @@ -394,6 +401,182 @@ describe('qwen-autofix workflow', () => { expect(run([{ ...llm, name: 'resolve-pr' }])).toBe('true'); }); + it('keeps a still-red check visible, but only once per head', () => { + // A red check is a STATE, not the instant it turned red. Counting only + // "failed since the watermark" made a still-failing PR invisible the + // moment the watermark passed the failure. Measured: #6451 (3 reds + // completed 09:30-09:51, watermark 10:55), #7357 (red 07:59, watermark + // 09:18), #7390 (red and watermark BOTH 11:27:37, so a strict `>` hid it + // the instant it appeared) — all three sat red for hours while every scan + // logged "nothing new". + const block = reviewScanJob.match( + /LIVE_HEAD="\$\(jq -r[\s\S]*?\n {12}fi\n/, + )?.[0]; + expect(block).toBeTruthy(); + const script = block.replace(/^ {12}/gm, ''); + const run = (reds, redHead, liveHead) => + execFileSync( + 'bash', + ['-c', `set -uo pipefail\n${script}\nprintf '%s' "$N_RED_NOW"`], + { + env: { + ...process.env, + PR_META: JSON.stringify({ headRefOid: liveHead }), + CHECKS_JSON: JSON.stringify( + reds.map((name) => ({ + name, + conclusion: 'FAILURE', + workflowName: 'CI', + })), + ), + RED_HEAD: redHead, + }, + encoding: 'utf8', + }, + ); + + // Never evaluated: the red is visible however old it is. + expect(run(['Test'], '', 'abc123')).toBe('1'); + // Already evaluated on THIS head: left alone. This is what bounds it to + // one look per head instead of re-selecting the PR every single scan. + expect(run(['Test'], 'abc123', 'abc123')).toBe('0'); + // A new commit re-opens it — the reds now belong to a head nobody judged. + expect(run(['Test'], 'old999', 'abc123')).toBe('1'); + // Green stays green. + expect(run([], '', 'abc123')).toBe('0'); + expect(run(['a', 'b', 'c'], '', 'abc123')).toBe('3'); + // Empty LIVE_HEAD → fail-closed regardless of RED_HEAD. Without this, + // a simplified guard (removing -n) would select a PR with no evaluable head. + expect(run(['Test'], '', '')).toBe('0'); + expect(run(['Test'], 'abc123', '')).toBe('0'); + + // The count must actually GATE selection — computing it and then not + // consulting it is the whole bug, and every other assertion here still + // passes without this line. + const idleGate = reviewScanJob.match( + /if \[\[ "\$\{N_REVIEWS\}" -eq 0[^\n]*\]\]; then/, + )?.[0]; + expect(idleGate).toBeTruthy(); + expect(idleGate).toContain('"${N_RED_NOW}" -eq 0'); + + // The head is recorded by its OWN marker inside the eval comment, so no + // ts/acted/round parser changes — and the comment still matches the eval + // filter, so the agent never sees it as feedback. + expect(reviewScanJob).toContain('autofix-redcheck head=([0-9a-f]+)'); + expect( + workflow.match(//g) ?? [], + ).toHaveLength(3); + // Every step that EMITS the marker must define REPORT_HEAD itself — a + // shell variable does not cross step boundaries — and no step may define + // it without emitting. Counting the two kinds separately missed exactly + // this: one assignment had landed in `issue-autofix`, which emits no + // marker and has no ${PR} in scope, while review-address's handoff step + // emitted the marker with the variable unset. Both counts were "right"; + // the pairing was not. + // Checked PER STEP BLOCK, not by step name: `Report dry-run / failure` + // exists in BOTH issue-autofix and review-address, so a name-keyed set + // merges them and the misplacement stays invisible — that is how the + // first version of this assertion passed while the bug was live. + let emitterSteps = 0; + for (const m of workflow.matchAll( + /\n {6}- name: '(?:[^']+)'\n([\s\S]*?)(?=\n {6}- name: '|\n {2}[a-z][a-z0-9-]*:\n|$)/g, + )) { + const body = m[1]; + const emits = body.includes( + '', + ); + const defines = body.includes('REPORT_HEAD="${CHECKED_OUT_HEAD}"'); + expect(emits).toBe(defines); + if (emits) emitterSteps += 1; + } + expect(emitterSteps).toBe(2); + // The head is captured in prepare (before agent mutations) and forwarded + // via a step output — not re-fetched from the API at report time, which + // could return a DIFFERENT head if the branch moved during the run. + expect(prepareBranchAndFeedbackStep).toContain( + 'CHECKED_OUT_HEAD="$(git rev-parse HEAD)"', + ); + expect(prepareBranchAndFeedbackStep).toContain( + 'checked_out_head=${CHECKED_OUT_HEAD}', + ); + expect(workflow).not.toContain('REPORT_HEAD="$(gh api'); + // The handoff step must NOT stamp the redcheck marker when the agent + // evaluated nothing (sentinel ts): doing so would make RED_HEAD == + // LIVE_HEAD and the retry scan would see N_RED_NOW=0, going idle + // despite the handoff promising a retry. + expect(reviewAddressReportStep).toContain( + 'if [[ "${MARK_TS}" != \'9999-12-31T23:59:59Z\' ]]; then', + ); + }); + + it('renders persistent red checks into the agent feedback', () => { + // The scan selects a PR via N_RED_NOW (currently-red, head unjudged), + // but the prepare step's "Failed checks" renderer only shows checks + // that completed AFTER the watermark. In the exact case N_RED_NOW + // targets (red completed before/equal to the watermark), the agent + // would receive an empty "Failed checks" section with no check name + // to reproduce. The "Still-red checks" section closes that gap. + expect(prepareBranchAndFeedbackStep).toContain( + '## Still-red checks (persisting from before the last evaluation)', + ); + // The still-red section must use <= (complement of the > in "Failed + // checks") so the two sections partition the red checks without overlap + // or gap. + const stillRedBlock = prepareBranchAndFeedbackStep.match( + /Still-red checks[\s\S]*?checks\.json"/, + )?.[0]; + expect(stillRedBlock).toBeTruthy(); + expect(stillRedBlock).toContain('<= $wm'); + // Must carry the same conclusion filter as N_RED_NOW (no CANCELLED: + // a cancelled check is not a persistent red state). + expect(stillRedBlock).toContain( + 'IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")', + ); + expect(stillRedBlock).not.toContain('CANCELLED'); + // Must carry the address-check carve-out, same as every other + // failed-check selector. + expect(stillRedBlock).toContain('startswith("review-address")'); + + // Behavioral: the jq filter renders a persistent red check that the + // "Failed checks" section (completedAt > wm) would miss. + const jqFilter = stillRedBlock.match( + /jq -r --arg wm.*?'([\s\S]*?)'\s*\\/, + )?.[1]; + expect(jqFilter).toBeTruthy(); + const checksJson = JSON.stringify([ + { + name: 'Test / unit', + conclusion: 'FAILURE', + workflowName: 'CI', + completedAt: '2026-01-01T09:00:00Z', + }, + { + name: 'Lint', + conclusion: 'SUCCESS', + workflowName: 'CI', + completedAt: '2026-01-01T09:00:00Z', + }, + { + name: 'Build', + conclusion: 'FAILURE', + workflowName: 'CI', + completedAt: '2026-01-01T11:00:00Z', + }, + ]); + const result = execFileSync( + 'jq', + ['-r', '--arg', 'wm', '2026-01-01T10:00:00Z', jqFilter], + { encoding: 'utf8', input: checksJson }, + ); + // Test / unit completed BEFORE the watermark: shown in still-red. + expect(result).toContain('Test / unit: FAILURE'); + // Build completed AFTER the watermark: NOT in still-red (it is in + // "Failed checks" instead). + expect(result).not.toContain('Build'); + // Green check: never shown. + expect(result).not.toContain('Lint'); + }); + it('bounds fleet-wide simultaneity below the per-scan target budget', () => { // max-parallel is the ONE place different PRs wait on each other: the scan // emits every eligible PR (up to MAX_TARGETS_PER_SCAN) and the matrix @@ -444,6 +627,9 @@ describe('qwen-autofix workflow', () => { // Default: the job was selected under the CURRENT window (the latest // ack, or 'none' before any takeover) — the normal, non-raced case. window = undefined, + // The head this job checked out (CHECKED_OUT_HEAD). The no-op same-head + // duplicate signature compares the live redcheck marker against it. + head = 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111', }) => { const effWindow = window ?? (acks.length ? acks[acks.length - 1] : 'none'); @@ -455,7 +641,7 @@ describe('qwen-autofix workflow', () => { ...marks.map((m) => ({ user: { login: 'qwen-code-dev-bot' }, created_at: m.at ?? '2026-07-18T09:00:00Z', - body: `eval `, + body: `eval ${m.head ? `\n` : ''}`, })), ...acks.map((at) => ({ user: { login: 'qwen-code-dev-bot' }, @@ -491,6 +677,7 @@ describe('qwen-autofix workflow', () => { CONFLICT: conflict, MAX_ROUNDS: '5', WINDOW: effWindow, + CHECKED_OUT_HEAD: head, AUTOFIX_BOT: 'qwen-code-dev-bot', REVIEW_BOT: 'qwen-code-ci-bot', TRUSTED_ASSOC: '["OWNER","MEMBER","COLLABORATOR"]', @@ -668,6 +855,51 @@ describe('qwen-autofix workflow', () => { commands: ['2026-07-18T08:45:00Z'], }).stale, ).toBe(true); + // No-op same-head duplicate (signature c): two scans both emit the PR with + // watermark W; the first serialized job ends in a no-op, recording a + // redcheck marker for head H while keeping ts=W and round UNCHANGED. + // Neither the watermark nor the round trigger fires, so without the + // redcheck re-check the second job would run the agent again and post a + // duplicate report for the same head. + const H = 'aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111'; + expect( + runStaleGate({ + marks: [{ ts: W, round: 2, acted: 'false', head: H }], + conflict: 'false', + round: 2, + head: H, + }).stale, + ).toBe(true); + // A new commit moved the head: the sibling judged a DIFFERENT head, so + // this target is real work, not a duplicate. + expect( + runStaleGate({ + marks: [{ ts: W, round: 2, acted: 'false', head: H }], + conflict: 'false', + round: 2, + head: 'bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222', + }).stale, + ).toBe(false); + // Same head, but trusted feedback arrived after the watermark the no-op + // sibling evaluated through: the queued job has real work and proceeds. + expect( + runStaleGate({ + marks: [{ ts: W, round: 2, acted: 'false', head: H }], + conflict: 'false', + round: 2, + head: H, + reviews: [F2], + }).stale, + ).toBe(false); + // Same head, but a live conflict is actionable regardless of the redcheck. + expect( + runStaleGate({ + marks: [{ ts: W, round: 2, acted: 'false', head: H }], + conflict: 'true', + round: 2, + head: H, + }).stale, + ).toBe(false); }); it('behaviorally replays the eligibility recheck across lifecycle and label states', () => { @@ -1379,7 +1611,7 @@ describe('qwen-autofix workflow', () => { ).length - 1, ).toBe(2); expect(reviewScanJob).toContain( - '--json headRefName,statusCheckRollup,createdAt,labels', + '--json headRefName,headRefOid,statusCheckRollup,createdAt,labels', ); // Command-style comments are instructions, not feedback — excluded at // ALL FOUR feedback sites (scan count via $cf; NEWEST, LIVE_NEW, and @@ -2357,13 +2589,13 @@ describe('qwen-autofix workflow', () => { expect(workflow).toContain( '.[3] | map(select((.conclusion // .state // "")', ); - // Three sites: the NEWEST computation, the live-watermark revalidation, - // and the feedback rendering — all must share the same address-check - // carve-out. + // Four sites: the NEWEST computation, the live-watermark revalidation, + // the "Failed checks" rendering, and the "Still-red checks" rendering + // — all must share the same address-check carve-out. expect( prepareBranchAndFeedbackStep.match(/startswith\("review-address"\)/g) ?? [], - ).toHaveLength(3); + ).toHaveLength(4); expect(prepareBranchAndFeedbackStep).toContain( 'gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]', );