diff --git a/.github/scripts/ci-flaky-rerun.mjs b/.github/scripts/ci-flaky-rerun.mjs index a2fbc64b727..df719cd5f03 100644 --- a/.github/scripts/ci-flaky-rerun.mjs +++ b/.github/scripts/ci-flaky-rerun.mjs @@ -428,12 +428,27 @@ export function eligibleAttemptJob(job, target, runAttempt, options = {}) { } function safeReason(reason) { - return reason - .trim() - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('@', '@\u200b'); + return ( + reason + .trim() + // A newline lets a reason open a block-level Markdown construct: a + // line-start fence swallows the folded Chinese block and renders the + // tracking marker as visible code. Keep the reason a single line. + .replace(/\s*\n\s*/g, ' ') + // Bidi overrides can visually rewrite a notice maintainers trust when + // deciding whether to update a branch; the reason is classifier output + // derived from untrusted logs, so drop them like the markup below. + // The class is the full Unicode Bidi_Control set. + .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('@', '@\u200b') + ); +} + +function bilingualComment(en, zh, marker) { + return `${en}\n\n
\n中文说明\n\n${zh}\n\n
\n\n${marker}`; } export async function actOnDecision(client, target, decision) { @@ -474,7 +489,17 @@ export async function actOnDecision(client, target, decision) { const marker = markerFor(target, decision.action, count); if (decision.action === 'rerun') { await client.rerunFailedJobs(target.runId); - await client.comment(target.prNumber, marker); + // The notice must be visible: a marker-only comment left maintainers + // unable to tell a patrolled flake from a deterministic failure, so they + // clicked Update Branch — advancing the head and dismissing approvals. + await client.comment( + target.prNumber, + bilingualComment( + `CI Failure Patrol: this failure looks transient, not a PR regression: ${safeReason(decision.reason_en)}\n\nFailed jobs were re-run on the same commit. No Update Branch is needed — if the rerun passes, the failure was a flake.`, + `CI 失败巡逻:此失败疑似瞬时问题,而非 PR 引入的回归:${safeReason(decision.reason_zh)}\n\n已在同一 commit 上重跑失败的 job,无需 Update Branch;重跑通过即为 flake。`, + marker, + ), + ); // A flaky TEST (not infra) also gets a deflake issue so the autofix loop // can stabilize it. Best-effort: the rerun + marker already succeeded, so a // transient issue-creation failure must not propagate (it would surface as @@ -490,10 +515,21 @@ export async function actOnDecision(client, target, decision) { } else if (decision.action === 'comment') { await client.comment( target.prNumber, - `CI is failing because: ${safeReason(decision.reason_en)}\n\n
\n中文说明\n\n${safeReason(decision.reason_zh)}\n\n
\n\n${marker}`, + bilingualComment( + `CI is failing because: ${safeReason(decision.reason_en)}`, + safeReason(decision.reason_zh), + marker, + ), ); } else { - await client.comment(target.prNumber, marker); + await client.comment( + target.prNumber, + bilingualComment( + `CI Failure Patrol: this failure could not be confidently classified: ${safeReason(decision.reason_en)}\n\nNo automatic action was taken. Inspect the failed job before rerunning or updating the branch.`, + `CI 失败巡逻:无法确信归类此失败:${safeReason(decision.reason_zh)}\n\n未采取自动动作。请先人工查看失败 job,再决定重跑或更新分支。`, + marker, + ), + ); } } diff --git a/.qwen/skills/ci-flaky-patrol/SKILL.md b/.qwen/skills/ci-flaky-patrol/SKILL.md index 07d3f18fefc..6c6f6879eae 100644 --- a/.qwen/skills/ci-flaky-patrol/SKILL.md +++ b/.qwen/skills/ci-flaky-patrol/SKILL.md @@ -11,7 +11,7 @@ For every candidate, choose exactly one action: - `rerun`: concrete transient evidence such as a runner/network timeout, interrupted infrastructure, transient install/download failure, or explicit flaky-test evidence. - `comment`: the failure is clearly caused by the PR. Compare the failure with `changedFiles`; the reason must state the causal evidence, not merely that the failure is deterministic. -- `no_action`: evidence is ambiguous, unsafe, incomplete, or does not justify another action. This still records an internal tracking marker on the PR. +- `no_action`: evidence is ambiguous, unsafe, incomplete, or does not justify another action. This still records a tracking marker and a short manual-review notice on the PR. When (and ONLY when) the `rerun` cause is a nondeterministic **TEST** — a specific named test that timed out, is order-dependent, or depends on wall-clock/randomness — also identify it so the loop can open a deflake fix. Add a `flakyTest` object with the exact failing `file` (repo-relative path) and `name` (the full test title, e.g. `describe › it`) taken verbatim from the log. Emit `flakyTest` ONLY for genuine test nondeterminism, NEVER for infra flakiness (ENOSPC, network, runner death, dependency download) — those get a plain `rerun` with no `flakyTest`. If the log does not name a specific test, omit `flakyTest`. Keep `file` and `name` each at most 200 characters (take the test title verbatim; if a nested `describe › it` chain is longer, keep the most specific tail). A malformed or over-length `flakyTest` is simply ignored — the rerun still happens — so never drop a valid rerun over it. diff --git a/scripts/tests/ci-flaky-rerun.test.js b/scripts/tests/ci-flaky-rerun.test.js index e6697b733ea..27765f326f1 100644 --- a/scripts/tests/ci-flaky-rerun.test.js +++ b/scripts/tests/ci-flaky-rerun.test.js @@ -545,13 +545,35 @@ describe('ci flaky rerun patrol', () => { expect(kinds).toContain('comment'); }); - it('reruns before recording the hidden action marker', async () => { + it('reruns before posting the visible rerun notice', async () => { const c = client(); await actOnDecision(c, target(), decision()); - expect(c.calls).toEqual([ - ['rerunFailedJobs', 123], - ['comment', 42, expect.stringMatching(/^$/)], + expect(c.calls.map((call) => call[0])).toEqual([ + 'rerunFailedJobs', + 'comment', ]); + const body = c.calls[1][2]; + expect(body).toContain('runner timeout'); + expect(body).toContain('re-run on the same commit'); + expect(body).toContain('No Update Branch'); + expect(body).toContain('中文说明'); + expect(body).toContain('runner 超时'); + expect(body.split('').length - 1).toBe(1); + expect(body).toMatch(/$/); + }); + + it('keeps markers parseable inside the visible notices', async () => { + // The visible text must not break dedup or the per-head action count: + // both read the marker back out of the posted comment body. + const c = client(); + await actOnDecision(c, target(), decision()); + const posted = { + body: c.calls.find((call) => call[0] === 'comment')[2], + createdAt: '2026-07-12T07:25:00.000Z', + author: { login: 'patrol-bot' }, + }; + expect(alreadyHandled([posted], target(), 'patrol-bot')).toBe(true); + expect(currentActionCount(pr(), [posted], 'patrol-bot')).toBe(1); }); it('posts deterministic failures in English with folded Chinese', async () => { @@ -566,22 +588,36 @@ describe('ci flaky rerun patrol', () => { }), ); expect(c.calls).toHaveLength(1); - expect(c.calls[0][2]).toContain('TypeScript cannot resolve'); - expect(c.calls[0][2]).toContain('
'); - expect(c.calls[0][2]).toContain('TypeScript 无法解析'); - expect(c.calls[0][2]).toContain('action=comment'); + const body = c.calls[0][2]; + expect(body).toContain('TypeScript cannot resolve'); + expect(body).toContain('
'); + expect(body).toContain('TypeScript 无法解析'); + expect(body).toContain('action=comment'); + // Pin the bilingual placement, not just the presence of both strings: + // English before the fold, Chinese inside it. + const enIndex = body.indexOf('TypeScript cannot resolve'); + const foldIndex = body.indexOf('中文说明'); + const zhIndex = body.indexOf('TypeScript 无法解析'); + expect(enIndex).toBeLessThan(foldIndex); + expect(foldIndex).toBeLessThan(zhIndex); + expect(body.split('
').length - 1).toBe(1); }); - it('records no_action without a visible comment', async () => { + it('records no_action with a visible manual-review notice', async () => { const c = client(); await actOnDecision( c, target(), decision({ action: 'no_action', confidence: 'low' }), ); - expect(c.calls).toEqual([ - ['comment', 42, expect.stringMatching(/^$/)], - ]); + expect(c.calls).toHaveLength(1); + const body = c.calls[0][2]; + expect(body).toContain('could not be confidently classified'); + expect(body).toContain('runner timeout'); + expect(body).toContain('中文说明'); + expect(body).toContain('未采取自动动作'); + expect(body.split('
').length - 1).toBe(1); + expect(body).toMatch(/$/); }); it('rejects malformed, unsafe, or mismatched decisions', async () => { @@ -604,19 +640,110 @@ describe('ci flaky rerun patrol', () => { }); it('neutralizes mentions and markup copied into visible reasons', async () => { - const c = client(); - await actOnDecision( - c, - target(), - decision({ - action: 'comment', - reason_en: '@qwen-maintainers ', - reason_zh: '@全体成员 ', - }), - ); - expect(c.calls[0][2]).toContain('@\u200bqwen-maintainers'); - expect(c.calls[0][2]).toContain('<script>'); - expect(c.calls[0][2]).toContain('@\u200b全体成员 </details>'); + // All three actions now surface the reason in a visible comment, so the + // escaping must hold for every action, not just `comment`. + for (const action of ['rerun', 'comment', 'no_action']) { + const c = client(); + await actOnDecision( + c, + target(), + decision({ + action, + confidence: action === 'no_action' ? 'low' : 'high', + reason_en: '@qwen-maintainers ', + reason_zh: '@全体成员 ', + }), + ); + const body = c.calls.find((call) => call[0] === 'comment')[2]; + expect(body).toContain('@\u200bqwen-maintainers'); + expect(body).toContain('<script>'); + expect(body).toContain('@\u200b全体成员 </details>'); + } + }); + + it('strips bidi overrides that could visually rewrite a notice', async () => { + // The reason is classifier output derived from untrusted logs; a bidi + // override could make "no Update Branch needed" read as its opposite. + // Every member of the Unicode Bidi_Control set is also fed individually + // so narrowing the strip class cannot survive this pin. + const bidiControls = [ + '\u061C', + '\u200E', + '\u200F', + '\u202A', + '\u202B', + '\u202C', + '\u202D', + '\u202E', + '\u2066', + '\u2067', + '\u2068', + '\u2069', + ]; + for (const action of ['rerun', 'comment', 'no_action']) { + const confidence = action === 'no_action' ? 'low' : 'high'; + const c = client(); + await actOnDecision( + c, + target(), + decision({ + action, + confidence, + reason_en: 'safe \u202Ereversed\u202C text', + reason_zh: '安全 \u2066隔离\u2069 \u061C文本', + }), + ); + const body = c.calls.find((call) => call[0] === 'comment')[2]; + expect(body).not.toMatch( + /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/, + ); + expect(body).toContain('safe reversed text'); + expect(body).toContain('安全 隔离 文本'); + for (const ch of bidiControls) { + const cc = client(); + await actOnDecision( + cc, + target(), + decision({ + action, + confidence, + reason_en: `a${ch}b`, + reason_zh: `a${ch}b`, + }), + ); + const single = cc.calls.find((call) => call[0] === 'comment')[2]; + expect(single).not.toContain(ch); + expect(single).toContain('ab'); + } + } + }); + + it('collapses newlines so a reason cannot open a block-level fence', async () => { + // A line-start fence in a reason swallows the folded Chinese block and + // renders the tracking marker as visible code; keep reasons single-line. + for (const action of ['rerun', 'comment', 'no_action']) { + const c = client(); + await actOnDecision( + c, + target(), + decision({ + action, + confidence: action === 'no_action' ? 'low' : 'high', + reason_en: 'npm error:\n```\nnpm error code ETIMEDOUT', + reason_zh: 'npm 错误:\n```\nnpm error code ETIMEDOUT', + }), + ); + const body = c.calls.find((call) => call[0] === 'comment')[2]; + expect(body).not.toMatch(/^```/m); + expect(body).toContain('npm error: ``` npm error code ETIMEDOUT'); + expect(body.split('').length - 1).toBe(1); + const posted = { + body, + createdAt: '2026-07-12T07:25:00.000Z', + author: { login: 'patrol-bot' }, + }; + expect(alreadyHandled([posted], target(), 'patrol-bot')).toBe(true); + } }); it('stops after three actions on the same head', async () => {