From 17cc319e2bfde3bae12a37e6d19cc0f7b6af29f5 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 15 Sep 2026 15:22:22 +0800 Subject: [PATCH 1/3] fix(ci): stop the queue-expiry body naming a cause it cannot observe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #11855, from its sandboxed verification report. The never-started branch fires on `runner_name` empty AND `steps` empty, which is a shape, not a cause. Verification sampled eight recently cancelled qwen-code-pr-review.yml runs and found three with exactly that shape, cancelled after 9.7, 36.4 and 33.7 minutes — so the body's claim that GitHub ended the job "at the 24-hour queue limit" was false by a factor of 40-150x. All three were cancellations caused by the PR closing, and the step's `pr_state != OPEN` gate runs before body selection, so none of them posted; the residual reachable case is an operator cancelling a review still queued, or a command-triggered run cancelled while it waited, both on an OPEN PR where the head-drift exit does not apply either. That reader is told a duration that did not elapse and is sent to qwen-review-runner-schedule.yml for something the schedule did not do. The first sentence now states only the observation, and cancellation joins the candidate list the body already disclaims with. Under the steady-state schedule a queued review waits at most 12 h and then runs, so a genuine cap expiry means the schedule itself is broken — the claim was true only in the failure mode it was written for. The five tests added in #11855 pin WHETHER the branch fires; none pinned WHICH cause it claims. The queue-expiry case now asserts the body names no duration and does offer cancellation, for both `failure` and `cancelled` results. Restoring "at the 24-hour queue limit" turns it red. Also records two .size-baseline numbers that were wrong on main: qwen-review-runner-schedule.yml 1958 -> 2518 (the ratchet's own entry, drifted inside #11855 when the main-fence comment landed without re-recording; the file could have grown 140% before the gate objected) and ci.yml 134426 -> 137297 (main-side and pre-existing, but it left only 1225 bytes of headroom, so the next PR touching ci.yml would be told to account for 2871 bytes of growth it did not cause — the red-wall class check-workflow-size.sh exists to prevent). qwen-code-pr-review.yml is re-recorded for this change. Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 6 +++--- .github/workflows/qwen-code-pr-review.yml | 2 +- scripts/tests/qwen-pr-review-workflow.test.js | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index c1c613c9831..ce96ddf5f87 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -19,7 +19,7 @@ 9256 build-and-publish-image.yml 49610 cd-cua-driver.yml 2076 cd-mobile-mcp.yml -134426 ci.yml +137297 ci.yml 1482 codeql.yml 9389 comment-attachment-guard.yml 1634 desktop-packaging-check.yml @@ -40,11 +40,11 @@ 5942 qwen-autofix-fork-signal.yml 469165 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml -265415 qwen-code-pr-review.yml +265436 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml 22680 qwen-issue-followup-bot.yml 5760 qwen-pr-safety-precheck.yml -1958 qwen-review-runner-schedule.yml +2518 qwen-review-runner-schedule.yml 27648 qwen-triage-finalize.yml 350381 qwen-triage.yml 9657 release-sdk-java.yml diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index 25d7e20fbb8..eb9f3e1af32 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -3298,7 +3298,7 @@ jobs: # `[workflow logs](RUN_URL)` markdown link the cross-job dedup # anchors on, and the retry instruction the timeout flavor needs. if [ "$never_started" = "true" ]; then - body="**Qwen Code review never started.** No runner picked up the review job before GitHub ended it at the 24-hour queue limit, so no review ran for this head and nothing picks it up on its own. This step cannot see WHY no runner was available: \`ecs-review\` is switched by \`qwen-review-runner-schedule.yml\`, so check its latest run — a pool that is closed by schedule, empty, or all-offline expires the next request the same way. Re-request with \`@qwen-code /review\` once that run shows the pool open. See [workflow logs](${RUN_URL})." + body="**Qwen Code review never started.** No runner picked up the review job before GitHub ended it, so no review ran for this head and nothing picks it up on its own. This step cannot see why: the \`ecs-review\` pool may be closed by schedule, empty or all-offline — \`qwen-review-runner-schedule.yml\` switches it, and its latest run tells those apart — or the run was cancelled while the job was still waiting. Re-request with \`@qwen-code /review\`; if it ends the same way, read that schedule run before requesting again. See [workflow logs](${RUN_URL})." elif [ "$REVIEW_PR_RESULT" = "cancelled" ]; then body="**Qwen Code review was cancelled before a review could be posted.** Nothing failed and nothing is retried automatically: the run was cancelled — by an operator, an upstream event, or the job exceeding its execution time limit. If you still want a review of this PR, request one with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})." else diff --git a/scripts/tests/qwen-pr-review-workflow.test.js b/scripts/tests/qwen-pr-review-workflow.test.js index 56dc0b0b9d9..8e8593c2ae8 100644 --- a/scripts/tests/qwen-pr-review-workflow.test.js +++ b/scripts/tests/qwen-pr-review-workflow.test.js @@ -4190,6 +4190,17 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { expect(r.posted).toContain('@qwen-code /review'); expect(r.posted).not.toContain('did not complete successfully'); expect(r.posted).not.toContain('retried automatically'); + // Which CAUSE the body claims is a separate axis from whether the + // branch fires, and it is the one the predicate cannot observe: real + // cancelled runs reach this shape in 10-40 minutes rather than at the + // 24-hour queue cap (an operator cancelling a review still queued, or + // a command-triggered run cancelled while it waited — both on an OPEN + // PR, so the pr_state gate does not filter them). Naming a duration + // would be false by a factor of 40-150x and would send the reader to + // the schedule workflow for something the schedule did not do. + expect(r.posted).not.toContain('24-hour'); + expect(r.posted).not.toContain('queue limit'); + expect(r.posted).toContain('cancelled while the job was still waiting'); expect(r.summary).toContain('never started by a runner'); } }, From b297b5cf2b7a8676453471ab5de3843bb21dc4b6 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Tue, 15 Sep 2026 16:00:34 +0800 Subject: [PATCH 2/3] ci: reduce to the two stale .size-baseline integers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's #11912 (809aaa5e0eb3) landed the same cause-neutral rewrite of the never_started fallback body 23 seconds before this PR was opened, so the wording edit and its extra assertion are redundant churn now. Both files are back to main's bytes; what remains is the part that is still true and still unlanded — two baseline entries whose recorded sizes drifted on main. ci.yml recorded 134426 against 137297 real bytes: 2871 of the 4096 growth allowance already spent, 1225 left before an unrelated PR trips the ratchet on a file it never touched. qwen-review-runner-schedule.yml recorded 1958 against 2518, leaving 3536. Both re-measured with wc -c against this merged tree rather than carried over from the earlier draft. qwen-code-pr-review.yml is left at main's 265415 (real 265273, 142 under, inside SLACK_BYTES) since nothing in this PR changes that file any more. check-workflow-size.sh rc=0; workflow-size.test.js 214 passed. Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- .github/workflows/qwen-code-pr-review.yml | 2 +- scripts/tests/qwen-pr-review-workflow.test.js | 11 ----------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 5cf9c105745..bc836aafbb4 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -40,7 +40,7 @@ 5942 qwen-autofix-fork-signal.yml 469165 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml -265433 qwen-code-pr-review.yml +265415 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml 22680 qwen-issue-followup-bot.yml 5760 qwen-pr-safety-precheck.yml diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index 2737d29586f..0f1ebc21927 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -3298,7 +3298,7 @@ jobs: # `[workflow logs](RUN_URL)` markdown link the cross-job dedup # anchors on, and the retry instruction the timeout flavor needs. if [ "$never_started" = "true" ]; then - body="**Qwen Code review never started.** No runner picked up the review job before GitHub ended it, so no review ran for this head and nothing picks it up on its own. This step cannot determine the cause: the \`ecs-review\` pool may be closed by schedule, empty or all-offline — \`qwen-review-runner-schedule.yml\` switches it, and its latest run tells those apart — or the run was cancelled while the job was still waiting. Re-request with \`@qwen-code /review\`; if it ends the same way, read that schedule run before requesting again. See [workflow logs](${RUN_URL})." + body="**Qwen Code review never started.** The job ended before a runner picked it up, so no review ran for this head. This can happen when a queued run is cancelled or expires; this step cannot determine the cause. See [workflow logs](${RUN_URL}). Check the latest \`qwen-review-runner-schedule.yml\` run. If you still want a review, re-request with \`@qwen-code /review\` when the \`ecs-review\` pool is available." elif [ "$REVIEW_PR_RESULT" = "cancelled" ]; then body="**Qwen Code review was cancelled before a review could be posted.** Nothing failed and nothing is retried automatically: the run was cancelled — by an operator, an upstream event, or the job exceeding its execution time limit. If you still want a review of this PR, request one with \`@qwen-code /review\`. See [workflow logs](${RUN_URL})." else diff --git a/scripts/tests/qwen-pr-review-workflow.test.js b/scripts/tests/qwen-pr-review-workflow.test.js index 79c11826118..2d6b0de5e7e 100644 --- a/scripts/tests/qwen-pr-review-workflow.test.js +++ b/scripts/tests/qwen-pr-review-workflow.test.js @@ -4193,17 +4193,6 @@ describe('fallback comment resilience (PR #8894 incident class)', () => { expect(r.posted).toContain('@qwen-code /review'); expect(r.posted).not.toContain('did not complete successfully'); expect(r.posted).not.toContain('retried automatically'); - // Which CAUSE the body claims is a separate axis from whether the - // branch fires, and it is the one the predicate cannot observe: real - // cancelled runs reach this shape in 10-40 minutes rather than at the - // 24-hour queue cap (an operator cancelling a review still queued, or - // a command-triggered run cancelled while it waited — both on an OPEN - // PR, so the pr_state gate does not filter them). Naming a duration - // would be false by a factor of 40-150x and would send the reader to - // the schedule workflow for something the schedule did not do. - expect(r.posted).not.toContain('24-hour'); - expect(r.posted).not.toContain('queue limit'); - expect(r.posted).toContain('cancelled while the job was still waiting'); expect(r.summary).toContain('never started by a runner'); } }, From 9326ab86d48bf4aaf81470b6ef2baaaf75537b52 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 15 Sep 2026 16:49:49 +0800 Subject: [PATCH 3/3] fix(serve): preserve extension skill identity and enablement Co-authored-by: Qwen-Coder --- .../src/serve/workspace-skills-status.test.ts | 92 +++++++++++++------ .../cli/src/serve/workspace-skills-status.ts | 6 +- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/serve/workspace-skills-status.test.ts b/packages/cli/src/serve/workspace-skills-status.test.ts index 728d1b3e3d7..251037c63cd 100644 --- a/packages/cli/src/serve/workspace-skills-status.test.ts +++ b/packages/cli/src/serve/workspace-skills-status.test.ts @@ -463,7 +463,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { await fsp.mkdir(path.join(qwenHome, '.qwen'), { recursive: true }); await fsp.writeFile( path.join(qwenHome, '.qwen', 'settings.json'), - JSON.stringify({ skills: { enabled: ['inactive-skill'] } }), + JSON.stringify({ skills: { enabled: ['inactive:inactive-skill'] } }), ); const refreshRuntime = vi.spyOn(ExtensionManager.prototype, 'refreshTools'); const status = await createWorkspaceSkillsStatusProvider()(qwenHome); @@ -471,7 +471,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect(status.skills).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'active-skill', + name: 'active:active-skill', status: 'ok', level: 'extension', extensionName: 'active', @@ -486,7 +486,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { userInvocable: false, }), expect.objectContaining({ - name: 'inactive-skill', + name: 'inactive:inactive-skill', status: 'disabled', disabledReason: 'inactive_extension', extensionName: 'inactive', @@ -497,7 +497,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect(refreshRuntime).not.toHaveBeenCalled(); }); - it('preserves project precedence and appends same-name inactive sources', async () => { + it('keeps project and extension skills with the same authored name distinct', async () => { await writeExtension('active', ['shared']); await writeExtension('inactive', ['shared']); await fsp.writeFile( @@ -511,13 +511,19 @@ describe('createWorkspaceSkillsStatusProvider', () => { '---\nname: shared\ndescription: Project wins\n---\nBody', ); const status = await createWorkspaceSkillsStatusProvider()(qwenHome); - expect(status.skills.filter((s) => s.name === 'shared')).toMatchObject([ - { level: 'project', status: 'ok' }, + expect( + status.skills.filter((s) => + ['shared', 'active:shared', 'inactive:shared'].includes(s.name), + ), + ).toMatchObject([ + { name: 'active:shared', level: 'extension', status: 'ok' }, { + name: 'inactive:shared', level: 'extension', extensionName: 'inactive', disabledReason: 'inactive_extension', }, + { name: 'shared', level: 'project', status: 'ok' }, ]); }); @@ -537,7 +543,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { await fsp.writeFile( path.join(workspace, '.qwen', 'settings.json'), JSON.stringify({ - skills: { enabled: ['OPT-IN'], disabled: ['blocked'] }, + skills: { enabled: ['SUITE:OPT-IN'], disabled: ['blocked'] }, }), ); const manager = new ExtensionManager({ @@ -559,11 +565,15 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect( status.skills.filter((s) => s.extensionName === 'suite'), ).toMatchObject([ - { name: 'blocked', disabledReason: 'hard' }, + { name: 'suite:blocked', disabledReason: 'hard' }, // Manifest default is ON, but the workspace override turns it off. - { name: 'default-on', status: 'disabled', disabledReason: 'default' }, - { name: 'opt-in', status: 'ok' }, - { name: 'overridden', status: 'ok' }, + { + name: 'suite:default-on', + status: 'disabled', + disabledReason: 'default', + }, + { name: 'suite:opt-in', status: 'ok' }, + { name: 'suite:overridden', status: 'ok' }, ]); const otherStatus = await provider(other); expect( @@ -582,7 +592,9 @@ describe('createWorkspaceSkillsStatusProvider', () => { await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true }); const provider = createWorkspaceSkillsStatusProvider(); const readSkill = async () => - (await provider(workspace)).skills.find((s) => s.name === 'default-off'); + (await provider(workspace)).skills.find( + (s) => s.name === 'suite:default-off', + ); expect(await readSkill()).toMatchObject({ status: 'disabled', disabledReason: 'default', @@ -591,6 +603,14 @@ describe('createWorkspaceSkillsStatusProvider', () => { path.join(workspace, '.qwen', 'settings.json'), JSON.stringify({ skills: { enabled: ['default-off'] } }), ); + expect(await readSkill()).toMatchObject({ + status: 'disabled', + disabledReason: 'default', + }); + await fsp.writeFile( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ skills: { enabled: ['suite:default-off'] } }), + ); expect(await readSkill()).toMatchObject({ status: 'ok' }); await fsp.rm(path.join(qwenHome, 'extensions', 'suite'), { recursive: true, @@ -653,7 +673,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect(status.skills).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'linked-skill', + name: 'linked:linked-skill', extensionName: 'linked', installedPath: path.join( relocated, @@ -663,7 +683,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { ), }), expect.objectContaining({ - name: 'portable-skill', + name: 'portable:portable-skill', extensionName: 'portable', }), ]), @@ -683,7 +703,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { }); expect((await provider(qwenHome)).skills).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: 'visible', status: 'ok' }), + expect.objectContaining({ name: 'suite:visible', status: 'ok' }), ]), ); }); @@ -725,9 +745,13 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect( status.skills.filter((s) => s.level === 'extension'), ).toMatchObject([ - { name: 'active-skill', extensionDisplayName: expected, status: 'ok' }, { - name: 'inactive-skill', + name: 'active:active-skill', + extensionDisplayName: expected, + status: 'ok', + }, + { + name: 'inactive:inactive-skill', extensionDisplayName: expected, disabledReason: 'inactive_extension', }, @@ -754,13 +778,15 @@ describe('createWorkspaceSkillsStatusProvider', () => { const status = await createWorkspaceSkillsStatusProvider()(qwenHome); expect(status.initialized).toBe(true); // Discovery is gated: no active extension Skill is listed as usable... - expect(status.skills.some((s) => s.name === 'active-skill')).toBe(false); + expect(status.skills.some((s) => s.name === 'active:active-skill')).toBe( + false, + ); // ...but inactive management entries still appear, matching the child // producer, which appends them unconditionally. expect(status.skills).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'inactive-skill', + name: 'inactive:inactive-skill', level: 'extension', disabledReason: 'inactive_extension', }), @@ -803,16 +829,16 @@ describe('createWorkspaceSkillsStatusProvider', () => { .filter((s) => s.level === 'extension') .map((s) => `${s.name}:${s.extensionDisplayName ?? ''}`); expect(await readNames()).toEqual([ - 'active-skill:Extension', - 'inactive-skill:Extension', + 'active:active-skill:Extension', + 'inactive:inactive-skill:Extension', ]); await fsp.writeFile( path.join(workspace, '.qwen', 'settings.json'), JSON.stringify({ general: { language: 'zh' } }), ); expect(await readNames()).toEqual([ - 'active-skill:扩展', - 'inactive-skill:扩展', + 'active:active-skill:扩展', + 'inactive:inactive-skill:扩展', ]); expect(refresh).toHaveBeenCalledTimes(1); }); @@ -852,7 +878,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { JSON.stringify({ inactive: { overrides: ['!*'] } }), ); const status = await createWorkspaceSkillsStatusProvider()(qwenHome); - const duplicates = status.skills.filter((s) => s.name === 'dup'); + const duplicates = status.skills.filter((s) => s.name === 'inactive:dup'); expect(duplicates).toHaveLength(1); expect(duplicates[0]).not.toHaveProperty('extensionDisplayName'); }); @@ -919,7 +945,7 @@ describe('createWorkspaceSkillsStatusProvider', () => { status.skills .filter((skill) => skill.level === 'extension') .map((skill) => skill.name), - ).toEqual(['healthy-skill']); + ).toEqual(['healthy:healthy-skill']); }); it.skipIf(process.platform === 'win32')( @@ -964,8 +990,12 @@ describe('createWorkspaceSkillsStatusProvider', () => { expect( status.skills.filter((s) => s.level === 'extension'), ).toMatchObject([ - { name: 'first-skill', status: 'ok' }, - { name: 'second-skill', status: 'disabled', disabledReason: 'default' }, + { name: 'first:first-skill', status: 'ok' }, + { + name: 'second:second-skill', + status: 'disabled', + disabledReason: 'default', + }, ]); } const store = new ExtensionStore(); @@ -979,8 +1009,12 @@ describe('createWorkspaceSkillsStatusProvider', () => { const status = await provider(qwenHome); expect(status.initialized).toBe(true); expect(status.skills.filter((s) => s.level === 'extension')).toMatchObject([ - { name: 'first-skill', status: 'disabled', disabledReason: 'default' }, - { name: 'second-skill', status: 'ok' }, + { + name: 'first:first-skill', + status: 'disabled', + disabledReason: 'default', + }, + { name: 'second:second-skill', status: 'ok' }, ]); }); }); diff --git a/packages/cli/src/serve/workspace-skills-status.ts b/packages/cli/src/serve/workspace-skills-status.ts index 5225319c0d8..db85ffed25f 100644 --- a/packages/cli/src/serve/workspace-skills-status.ts +++ b/packages/cli/src/serve/workspace-skills-status.ts @@ -37,6 +37,8 @@ import { Storage, isSafeModeEnv, getExtensionDisplayName, + authoredSkillName, + qualifySkillName, } from '@qwen-code/qwen-code-core'; import type { Config, Extension, SkillLevel } from '@qwen-code/qwen-code-core'; import type { ServeWorkspaceSkillsStatus } from '@qwen-code/acp-bridge/status'; @@ -234,7 +236,7 @@ async function buildWorkspaceSkillsStatus( const enabled = extension ? extensionSkillStates .get(extension) - ?.get(skill.name.trim().toLowerCase()) + ?.get(authoredSkillName(skill).trim().toLowerCase()) : undefined; // Preserve missing display names; the helper otherwise falls back to the name. const localizedSkill = @@ -260,6 +262,8 @@ async function buildWorkspaceSkillsStatus( mapSkillConfigToStatus( { ...skill, + name: qualifySkillName(extension.name, skill.name), + authoredName: skill.name, level: 'extension', extensionName: extension.name, extensionDisplayName: