mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
ci(review): keep four runners on review through the daytime (#15769)
The review pool is label-switched: the whole hk1/hk2 fleet carries `ecs-review` overnight and none of it does between 05:00 and 17:00, so a review pushed during the day waits until the evening switch. Today that backlog is 65 queued review runs at 13:45. Give the daytime a small dedicated pool instead of moving the whole fleet: the morning switch now runs a new `day` mode, which keeps a named handful of runners on `ecs-review` and returns everyone else to `ecs-qwen`. Four hk2 runners are the default; a repository variable overrides the list. A name that matches no registered runner fails the tick instead of silently shrinking the pool, and an offline kept runner stays untouched like every other offline runner. The 17:00 switch still moves the whole fleet to review, so the four merge into the full pool at night.
This commit is contained in:
4 files changed
+143
-17
No files matched your search
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
// Switch all online hk1/hk2 runners between review and CI, twice a day.
|
||||
// `day` keeps a named handful on `ecs-review` so reviews start during the
|
||||
// daytime too, and returns the rest of the fleet to CI.
|
||||
// Offline runners are skipped; their labels stay unchanged.
|
||||
// Running jobs finish normally; labels only control which job starts next.
|
||||
|
||||
@@ -20,14 +22,38 @@ const MANAGED_LABELS = ['ecs-review', 'ecs-qwen'];
|
||||
// copies that can drift would reintroduce the silent no-op it prevents.
|
||||
const FLEET_NAME = /^ecs-qwen-hk[12]-\d+$/;
|
||||
|
||||
export function planLabels(runners, mode) {
|
||||
if (!['review', 'ci'].includes(mode)) {
|
||||
throw new Error('mode must be review or ci');
|
||||
const MODES = ['review', 'day', 'ci'];
|
||||
|
||||
// `dayKeep` is the runner-name list that stays on `ecs-review` in `day` mode.
|
||||
// Every other managed runner then serves CI, so the daytime review capacity is
|
||||
// exactly those runners and never the whole fleet.
|
||||
export function planLabels(runners, mode, dayKeep = []) {
|
||||
if (!MODES.includes(mode)) {
|
||||
throw new Error('mode must be review, day or ci');
|
||||
}
|
||||
const target = mode === 'review' ? 'ecs-review' : 'ecs-qwen';
|
||||
if (mode === 'day') {
|
||||
if (!dayKeep.length) {
|
||||
throw new Error('mode day needs at least one review-day runner name');
|
||||
}
|
||||
// Typo defence: a name that matches no registered hk1/hk2 runner would
|
||||
// silently shrink the daytime pool every morning. Offline runners stay
|
||||
// allowed — they keep their labels and rejoin when they come back.
|
||||
const registered = new Set(
|
||||
runners.filter((r) => FLEET_NAME.test(r.name)).map((r) => r.name),
|
||||
);
|
||||
const unknown = dayKeep.filter((name) => !registered.has(name));
|
||||
if (unknown.length) {
|
||||
throw new Error(
|
||||
`review-day runner names match no registered hk1/hk2 runner: ${unknown.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const keep = new Set(mode === 'day' ? dayKeep : []);
|
||||
return runners
|
||||
.filter((r) => r.status === 'online' && FLEET_NAME.test(r.name))
|
||||
.flatMap((r) => {
|
||||
const target =
|
||||
mode === 'review' || keep.has(r.name) ? 'ecs-review' : 'ecs-qwen';
|
||||
const labels = r.labels.map((l) => l.name);
|
||||
const remove = labels.filter(
|
||||
(l) => MANAGED_LABELS.includes(l) && l !== target,
|
||||
@@ -48,14 +74,21 @@ async function gh(args) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [repo, mode] = process.argv.slice(2);
|
||||
const [repo, mode, ...rest] = process.argv.slice(2);
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repo ?? '')) {
|
||||
throw new Error(
|
||||
'usage: review-runner-schedule.mjs <owner/repo> <review|ci>',
|
||||
'usage: review-runner-schedule.mjs <owner/repo> <review|day|ci> [review-day runner names]',
|
||||
);
|
||||
}
|
||||
// Validate the mode before spending an authenticated call on it.
|
||||
planLabels([], mode);
|
||||
const dayKeep = rest.join(' ').split(/\s+/).filter(Boolean);
|
||||
// Validate the mode (and the day list shape) before spending an
|
||||
// authenticated call on it.
|
||||
if (!MODES.includes(mode)) {
|
||||
throw new Error('mode must be review, day or ci');
|
||||
}
|
||||
if (mode === 'day' && !dayKeep.length) {
|
||||
throw new Error('mode day needs at least one review-day runner name');
|
||||
}
|
||||
if (!process.env.RUNNER_ADMIN_TOKEN) {
|
||||
throw new Error(
|
||||
'RUNNER_ADMIN_TOKEN is empty (needs Administration: write)',
|
||||
@@ -73,7 +106,7 @@ async function main() {
|
||||
if (!runners.some((r) => FLEET_NAME.test(r.name))) {
|
||||
throw new Error('no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found');
|
||||
}
|
||||
const actions = planLabels(runners, mode);
|
||||
const actions = planLabels(runners, mode, dayKeep);
|
||||
const results = await Promise.all(
|
||||
actions.map(async ({ id, name, add, remove }) => {
|
||||
try {
|
||||
@@ -112,6 +145,7 @@ async function main() {
|
||||
);
|
||||
const summary = [
|
||||
`### hk1/hk2 runner pool: ${mode}`,
|
||||
...(mode === 'day' ? [`- keeping on review: ${dayKeep.join(', ')}`] : []),
|
||||
`- ${actions.length} runners to switch`,
|
||||
...results.map((r) => r.message),
|
||||
].join('\n');
|
||||
|
||||
@@ -82,7 +82,78 @@ describe('review runner schedule', () => {
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(planLabels([runner(1, ['ecs-qwen'])], 'ci'), []);
|
||||
assert.throws(() => planLabels([], 'invalid'), /mode must be review or ci/);
|
||||
assert.throws(
|
||||
() => planLabels([], 'invalid'),
|
||||
/mode must be review, day or ci/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the named runners on review and returns the rest to CI in day mode', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
[
|
||||
runner(1, ['ecs-qwen', 'ecs-autofix'], { name: 'ecs-qwen-hk2-1' }),
|
||||
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hk2-2' }),
|
||||
runner(3, ['ecs-review'], { name: 'ecs-qwen-hk1-7' }),
|
||||
runner(4, ['ecs-qwen'], { name: 'ecs-qwen-hk1-8' }),
|
||||
runner(5, ['ecs-qwen'], { name: 'ecs-qwen-hk3-9' }),
|
||||
],
|
||||
'day',
|
||||
['ecs-qwen-hk2-1'],
|
||||
),
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: 'ecs-qwen-hk2-1',
|
||||
add: ['ecs-review'],
|
||||
remove: ['ecs-qwen'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'ecs-qwen-hk1-7',
|
||||
add: ['ecs-qwen'],
|
||||
remove: ['ecs-review'],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('is idempotent for day mode and rejects a review-day name the fleet lacks', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
[runner(1, ['ecs-review'], { name: 'ecs-qwen-hk2-1' })],
|
||||
'day',
|
||||
['ecs-qwen-hk2-1'],
|
||||
),
|
||||
[],
|
||||
);
|
||||
// A typo would otherwise shrink the daytime pool on every morning tick
|
||||
// while the run stays green.
|
||||
assert.throws(
|
||||
() => planLabels([runner(1, ['ecs-qwen'])], 'day', ['ecs-qwen-hk9-1']),
|
||||
/match no registered hk1\/hk2 runner/,
|
||||
);
|
||||
assert.throws(
|
||||
() => planLabels([], 'day', []),
|
||||
/needs at least one review-day runner name/,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an offline review-day runner untouched', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
[
|
||||
runner(1, ['ecs-review'], {
|
||||
name: 'ecs-qwen-hk2-1',
|
||||
status: 'offline',
|
||||
}),
|
||||
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hk2-2' }),
|
||||
],
|
||||
'day',
|
||||
['ecs-qwen-hk2-1'],
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('wires two daily UTC switches and manual pool selection', () => {
|
||||
@@ -101,11 +172,20 @@ describe('review runner schedule', () => {
|
||||
);
|
||||
assert.ok(
|
||||
schedule.includes(
|
||||
"github.event.schedule == '0 9 * * *' && 'review' || 'ci'",
|
||||
"github.event.schedule == '0 9 * * *' && 'review' || 'day'",
|
||||
),
|
||||
);
|
||||
assert.ok(schedule.includes('"$GITHUB_REPOSITORY" "$POOL"'));
|
||||
assert.ok(schedule.includes('secrets.RUNNER_ADMIN_PAT'));
|
||||
// The daytime pool. Four names stay on review after the 05:00 switch; an
|
||||
// unset override falls back to them, and a name the fleet does not have
|
||||
// fails the tick instead of shrinking the pool silently.
|
||||
assert.ok(schedule.includes("- 'day'"));
|
||||
assert.match(
|
||||
schedule,
|
||||
/REVIEW_DAY_HOSTS: .*'ecs-qwen-hk2-1 ecs-qwen-hk2-2 ecs-qwen-hk2-3 ecs-qwen-hk2-4'/,
|
||||
);
|
||||
assert.ok(schedule.includes('"${day_hosts[@]}"'));
|
||||
// The in-repo half of the fence on the admin PAT. Losing it is silent:
|
||||
// a dispatch from any other branch still runs, now executing that
|
||||
// branch's copy of the planner with the token in its environment. The
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
107075 qwen-fleet-shepherd.yml
|
||||
22680 qwen-issue-followup-bot.yml
|
||||
5760 qwen-pr-safety-precheck.yml
|
||||
2518 qwen-review-runner-schedule.yml
|
||||
3243 qwen-review-runner-schedule.yml
|
||||
27648 qwen-triage-finalize.yml
|
||||
362377 qwen-triage.yml
|
||||
15171 release-chrome-extension.yml
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
name: 'Qwen Review Runner Schedule'
|
||||
|
||||
# Switch every online hk1/hk2 runner at 17:00 / 05:00 Asia/Shanghai.
|
||||
# GitHub cron uses UTC. Manual runs can select either pool explicitly.
|
||||
# 17:00 puts the whole fleet on review; 05:00 returns it to CI but keeps
|
||||
# REVIEW_DAY_HOSTS on review, so four reviews can start during the daytime
|
||||
# instead of queueing until the evening. GitHub cron uses UTC.
|
||||
# Manual runs can select a pool explicitly.
|
||||
# Running jobs finish; only the labels for the next job change.
|
||||
#
|
||||
# REVIEW_DAY_HOSTS is a space-separated list of hk1/hk2 runner names that
|
||||
# stay on `ecs-review` in the `day` pool. Unset falls back to the four hk2
|
||||
# runners below; the script fails loudly if a name matches no registered
|
||||
# runner, so a typo cannot silently shrink the daytime pool.
|
||||
#
|
||||
# RUNNER_ADMIN_PAT needs runner-label write access: repo scope for a
|
||||
# classic PAT, or repository Administration: write for a fine-grained PAT.
|
||||
# Store it in the environment below, restricted to `main`.
|
||||
@@ -11,8 +19,8 @@ name: 'Qwen Review Runner Schedule'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *' # 17:00 Asia/Shanghai: enable reviews
|
||||
- cron: '0 21 * * *' # 05:00 Asia/Shanghai: return to CI
|
||||
- cron: '0 9 * * *' # 17:00 Asia/Shanghai: whole fleet on review
|
||||
- cron: '0 21 * * *' # 05:00 Asia/Shanghai: CI, REVIEW_DAY_HOSTS on review
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pool:
|
||||
@@ -22,6 +30,7 @@ on:
|
||||
default: 'ci'
|
||||
options:
|
||||
- 'review'
|
||||
- 'day'
|
||||
- 'ci'
|
||||
|
||||
permissions:
|
||||
@@ -59,5 +68,8 @@ jobs:
|
||||
- name: 'Switch runner pool'
|
||||
env:
|
||||
RUNNER_ADMIN_TOKEN: '${{ secrets.RUNNER_ADMIN_PAT }}'
|
||||
POOL: "${{ github.event_name == 'workflow_dispatch' && inputs.pool || (github.event.schedule == '0 9 * * *' && 'review' || 'ci') }}"
|
||||
run: 'node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL"'
|
||||
POOL: "${{ github.event_name == 'workflow_dispatch' && inputs.pool || (github.event.schedule == '0 9 * * *' && 'review' || 'day') }}"
|
||||
REVIEW_DAY_HOSTS: "${{ vars.QWEN_REVIEW_DAY_HOSTS || 'ecs-qwen-hk2-1 ecs-qwen-hk2-2 ecs-qwen-hk2-3 ecs-qwen-hk2-4' }}"
|
||||
run: |
|
||||
read -r -a day_hosts <<< "$REVIEW_DAY_HOSTS"
|
||||
node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL" "${day_hosts[@]}"
|
||||
Reference in new issue
Block a user