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:
易良 authored and GitHub committed 2026-10-28 15:34:22 +08:00
1 parent 046095c071
commit 8ef796af37
4 files changed
+143 -17

No files matched your search

+43 -9
View File
@@ -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
+1 -1
View File
@@ -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[@]}"