mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(ci): drive fleet by pattern var, filter by capability, soften day cap
Address review feedback on PR #16544: - Read QWEN_REVIEW_FLEET_NAME_PATTERN from the environment so a region change is a var edit, not a code change. Default stays on the hz fleet. - Filter the managed set by capability: a runner must carry the X64 label (the architecture review jobs target) and must not carry a reserved label such as ecs-qwen-release-host. Without this, the day pool nominally sized at 40 actually has 24 usable slots because hz1-17..hz1-32 are ARM64-only, and the planner would also try to relabel the dedicated release hosts. - Strict integer parsing for the day count: a /^\d+$/ gate before Number() so trailing garbage (4O, 40.9, 1e2) cannot silently coerce and shrink the daytime pool. - Soft cap instead of hard fail when dayCount exceeds the online fleet: truncate to whatever is online and warn in the run summary. The previous behaviour left every runner in its previous pool for up to 12 hours when a stale var overran reality. - Restore the per-name "keeping on review" listing in the step summary so operators can see which hosts are in the day pool without re- running the planner by hand. - Drop hk1/hk2 references from the schedule workflow and the review workflow's runs-on comment; the script no longer cares about regions. Add an end-to-end day-mode CLI test that drives the planner through a fake gh and asserts the label calls and the kept-names summary. Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
0d5ac6506b
commit
3929cb9c61
4 files changed
+384
-96
No files matched your search
@@ -5,7 +5,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Switch all online hz1-hz6 runners between review and CI, twice a day.
|
||||
// Switch the managed runner fleet between review and CI, twice a day.
|
||||
// `day` keeps a configured count 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.
|
||||
@@ -17,55 +17,79 @@ import { promisify } from 'node:util';
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const MANAGED_LABELS = ['ecs-review', 'ecs-qwen'];
|
||||
// One literal for both the switch filter and the fleet guard below: the
|
||||
// guard's whole job is to prove the filter will match something, so two
|
||||
// copies that can drift would reintroduce the silent no-op it prevents.
|
||||
const FLEET_NAME = /^ecs-qwen-hz[1-6]-\d+$/;
|
||||
// Labels that mark a runner as dedicated to a non-review workload (release
|
||||
// hosts, benchmarking, etc). They must never receive an `ecs-review` or
|
||||
// `ecs-qwen` label, so the planner filters them out by name pattern.
|
||||
const EXCLUDED_LABELS = new Set(['ecs-qwen-release-host']);
|
||||
// Architecture the review workflow requires (`runs-on: [..., "x64"]`). A
|
||||
// runner that lacks this label can never be assigned a review job, so it
|
||||
// does not count toward daytime review capacity.
|
||||
const REQUIRED_ARCH_LABEL = 'x64';
|
||||
|
||||
const MODES = ['review', 'day', 'ci'];
|
||||
|
||||
// Compile the fleet-name regex from configuration, never from a hardcoded
|
||||
// region. Falling back to a restrictive default keeps a forgotten variable
|
||||
// from silently widening the managed fleet on the next tick.
|
||||
export function fleetNameFromEnv(env = process.env) {
|
||||
const source =
|
||||
env.QWEN_REVIEW_FLEET_NAME_PATTERN || '^ecs-qwen-hz[1-6]-\\d+$';
|
||||
try {
|
||||
return new RegExp(source);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`QWEN_REVIEW_FLEET_NAME_PATTERN is not a valid regex: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// True when a runner is eligible to be managed by this schedule. Filters
|
||||
// out: runners without the architecture the review workflow targets, and
|
||||
// runners carrying a reserved label such as `ecs-qwen-release-host`.
|
||||
export function isManagedRunner(runner, fleetName) {
|
||||
if (!fleetName.test(runner.name)) return false;
|
||||
const labels = runner.labels.map((l) => l.name.toLowerCase());
|
||||
if (!labels.includes(REQUIRED_ARCH_LABEL)) return false;
|
||||
return !labels.some((l) => EXCLUDED_LABELS.has(l));
|
||||
}
|
||||
|
||||
// `dayCount` is how many runners stay on `ecs-review` in `day` mode. The
|
||||
// fleet is sorted by name and the first `dayCount` online runners keep the
|
||||
// label; every other managed runner then serves CI, so the daytime review
|
||||
// capacity is exactly that count and never the whole fleet. Numeric sort
|
||||
// (hz1-2 before hz1-10) keeps the pick deterministic across runs and across
|
||||
// changes in runner count.
|
||||
export function planLabels(runners, mode, dayCount = 0) {
|
||||
// fleet is sorted by name and the first `dayCount` online managed runners
|
||||
// keep the label; every other managed runner then serves CI, so the daytime
|
||||
// review capacity is exactly that count and never the whole fleet. Numeric
|
||||
// sort (hz1-2 before hz1-10) keeps the pick deterministic across runs and
|
||||
// across changes in runner count.
|
||||
export function planLabels(runners, mode, dayCount = 0, fleetName) {
|
||||
const fleet = fleetName ?? fleetNameFromEnv();
|
||||
if (!MODES.includes(mode)) {
|
||||
throw new Error('mode must be review, day or ci');
|
||||
}
|
||||
const eligible = runners.filter((r) => isManagedRunner(r, fleet));
|
||||
if (mode === 'day') {
|
||||
if (!Number.isInteger(dayCount) || dayCount < 1) {
|
||||
throw new Error(
|
||||
'mode day needs a positive integer review-day runner count',
|
||||
);
|
||||
}
|
||||
const online = runners.filter(
|
||||
(r) => r.status === 'online' && FLEET_NAME.test(r.name),
|
||||
);
|
||||
// Loud cap: a count larger than the online fleet would silently shrink
|
||||
// the daytime pool every morning. Better to fail the schedule and let
|
||||
// the operator lower the var than to drift to whatever happens to be
|
||||
// online.
|
||||
if (dayCount > online.length) {
|
||||
throw new Error(
|
||||
`review-day runner count ${dayCount} exceeds online fleet of ${online.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const sorted = runners
|
||||
.filter((r) => FLEET_NAME.test(r.name))
|
||||
const sorted = eligible
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
const onlineSorted = sorted.filter((r) => r.status === 'online');
|
||||
// Soft cap: an over-large count truncates to whatever is online, with a
|
||||
// warning the operator can read in the run summary. Hard-failing here
|
||||
// would leave every runner in its previous pool (potentially the whole
|
||||
// fleet on review at 05:00) until somebody notices.
|
||||
const effectiveDayCount =
|
||||
mode === 'day' ? Math.min(dayCount, onlineSorted.length) : 0;
|
||||
const truncated = mode === 'day' && dayCount > onlineSorted.length;
|
||||
const keep = new Set(
|
||||
mode === 'day'
|
||||
? sorted
|
||||
.filter((r) => r.status === 'online')
|
||||
.slice(0, dayCount)
|
||||
.map((r) => r.name)
|
||||
? onlineSorted.slice(0, effectiveDayCount).map((r) => r.name)
|
||||
: [],
|
||||
);
|
||||
return runners
|
||||
.filter((r) => r.status === 'online' && FLEET_NAME.test(r.name))
|
||||
const actions = eligible
|
||||
.filter((r) => r.status === 'online')
|
||||
.flatMap((r) => {
|
||||
const target =
|
||||
mode === 'review' || keep.has(r.name) ? 'ecs-review' : 'ecs-qwen';
|
||||
@@ -78,6 +102,14 @@ export function planLabels(runners, mode, dayCount = 0) {
|
||||
? [{ id: r.id, name: r.name, add, remove }]
|
||||
: [];
|
||||
});
|
||||
return {
|
||||
actions,
|
||||
keptNames: mode === 'day' ? [...keep] : [],
|
||||
truncated,
|
||||
requestedDayCount: dayCount,
|
||||
effectiveDayCount,
|
||||
onlineManaged: onlineSorted.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function gh(args) {
|
||||
@@ -95,22 +127,33 @@ async function main() {
|
||||
'usage: review-runner-schedule.mjs <owner/repo> <review|day|ci> [review-day runner count]',
|
||||
);
|
||||
}
|
||||
const dayCount = rest.length === 0 ? 0 : Number.parseInt(rest.join(' '), 10);
|
||||
// Validate the mode (and the day count 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' && (!Number.isInteger(dayCount) || dayCount < 1)) {
|
||||
throw new Error(
|
||||
'mode day needs a positive integer review-day runner count',
|
||||
);
|
||||
// Strict integer parse: `Number.parseInt` accepts trailing garbage (`4O`,
|
||||
// `40.9`, `1e2` all silently coerce), which would silently shrink the
|
||||
// daytime pool — the exact property this var is supposed to defend.
|
||||
const rawCount = rest.join(' ').trim();
|
||||
let dayCount = 0;
|
||||
if (mode === 'day') {
|
||||
if (!/^\d+$/.test(rawCount)) {
|
||||
throw new Error(
|
||||
'mode day needs a positive integer review-day runner count',
|
||||
);
|
||||
}
|
||||
dayCount = Number(rawCount);
|
||||
if (!Number.isSafeInteger(dayCount) || dayCount < 1) {
|
||||
throw new Error(
|
||||
'mode day needs a positive integer review-day runner count',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!process.env.RUNNER_ADMIN_TOKEN) {
|
||||
throw new Error(
|
||||
'RUNNER_ADMIN_TOKEN is empty (needs Administration: write)',
|
||||
);
|
||||
}
|
||||
const fleetName = fleetNameFromEnv();
|
||||
const pages = JSON.parse(
|
||||
await gh([
|
||||
'api',
|
||||
@@ -120,12 +163,14 @@ async function main() {
|
||||
]),
|
||||
);
|
||||
const runners = pages.flatMap((page) => page.runners);
|
||||
if (!runners.some((r) => FLEET_NAME.test(r.name))) {
|
||||
throw new Error('no ecs-qwen-hz[1-6]-<n> runner found');
|
||||
if (!runners.some((r) => isManagedRunner(r, fleetName))) {
|
||||
throw new Error(
|
||||
`no runner matches QWEN_REVIEW_FLEET_NAME_PATTERN (${fleetName.source})`,
|
||||
);
|
||||
}
|
||||
const actions = planLabels(runners, mode, dayCount);
|
||||
const plan = planLabels(runners, mode, dayCount, fleetName);
|
||||
const results = await Promise.all(
|
||||
actions.map(async ({ id, name, add, remove }) => {
|
||||
plan.actions.map(async ({ id, name, add, remove }) => {
|
||||
try {
|
||||
// Add before removing. A failed POST leaves the host in its previous
|
||||
// pool only; a failed DELETE leaves it in both. Neither leaves it with
|
||||
@@ -161,11 +206,22 @@ async function main() {
|
||||
}),
|
||||
);
|
||||
const summary = [
|
||||
`### hz runner pool: ${mode}`,
|
||||
`### runner pool: ${mode}`,
|
||||
`- fleet pattern: \`${fleetName.source}\``,
|
||||
...(mode === 'day'
|
||||
? [`- keeping on review: first ${dayCount} runners`]
|
||||
? [
|
||||
`- keeping on review (${plan.effectiveDayCount}${
|
||||
plan.truncated ? ` of requested ${plan.requestedDayCount}` : ''
|
||||
}):`,
|
||||
...plan.keptNames.map((name) => ` - ${name}`),
|
||||
]
|
||||
: []),
|
||||
`- ${actions.length} runners to switch`,
|
||||
...(plan.truncated
|
||||
? [
|
||||
`- ::warning::requested ${plan.requestedDayCount} day runners but only ${plan.onlineManaged} are online — pool truncated`,
|
||||
]
|
||||
: []),
|
||||
`- ${plan.actions.length} runners to switch`,
|
||||
...results.map((r) => r.message),
|
||||
].join('\n');
|
||||
console.log(summary);
|
||||
|
||||
@@ -11,17 +11,86 @@ import { tmpdir } from 'node:os';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it } from 'node:test';
|
||||
import { planLabels } from './review-runner-schedule.mjs';
|
||||
import {
|
||||
fleetNameFromEnv,
|
||||
isManagedRunner,
|
||||
planLabels,
|
||||
} from './review-runner-schedule.mjs';
|
||||
|
||||
const runner = (id, labels, extra = {}) => ({
|
||||
id,
|
||||
name: `ecs-qwen-hz2-${id}`,
|
||||
labels: labels.map((name) => ({ name })),
|
||||
status: 'online',
|
||||
...extra,
|
||||
});
|
||||
const FLEET = /^ecs-qwen-hz[1-6]-\d+$/;
|
||||
|
||||
// Every fixture runner needs the architecture label the review workflow
|
||||
// targets — without it `isManagedRunner` returns false and the planner
|
||||
// skips the runner entirely.
|
||||
const runner = (id, labels, extra = {}) => {
|
||||
const { name, ...rest } = extra;
|
||||
return {
|
||||
id,
|
||||
name: name ?? `ecs-qwen-hz2-${id}`,
|
||||
labels: ['X64', ...labels].map((n) => ({ name: n })),
|
||||
status: 'online',
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
const plan = (runners, mode, dayCount = 0) =>
|
||||
planLabels(runners, mode, dayCount, FLEET).actions;
|
||||
|
||||
describe('review runner schedule', () => {
|
||||
describe('fleetNameFromEnv', () => {
|
||||
it('falls back to the hz fleet when unset', () => {
|
||||
assert.equal(fleetNameFromEnv({}).source, '^ecs-qwen-hz[1-6]-\\d+$');
|
||||
});
|
||||
it('honours an override and rejects an invalid regex', () => {
|
||||
assert.equal(
|
||||
fleetNameFromEnv({ QWEN_REVIEW_FLEET_NAME_PATTERN: '^foo-\\d+$' })
|
||||
.source,
|
||||
'^foo-\\d+$',
|
||||
);
|
||||
assert.throws(
|
||||
() => fleetNameFromEnv({ QWEN_REVIEW_FLEET_NAME_PATTERN: '(' }),
|
||||
/not a valid regex/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isManagedRunner', () => {
|
||||
it('requires the review architecture label', () => {
|
||||
// hz1-17..hz1-32 carry ARM64 only — they can never be assigned a
|
||||
// review job whose runs-on includes "x64", so they must not count
|
||||
// toward the daytime pool.
|
||||
assert.equal(
|
||||
isManagedRunner(
|
||||
runner(1, ['ecs-qwen', 'ARM64'], {
|
||||
// strip the default X64 to model the real host
|
||||
}),
|
||||
FLEET,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const armOnly = {
|
||||
id: 17,
|
||||
name: 'ecs-qwen-hz1-17',
|
||||
labels: ['self-hosted', 'Linux', 'ARM64', 'ecs-qwen'].map((n) => ({
|
||||
name: n,
|
||||
})),
|
||||
status: 'online',
|
||||
};
|
||||
assert.equal(isManagedRunner(armOnly, FLEET), false);
|
||||
});
|
||||
it('excludes reserved-label hosts such as ecs-qwen-release-host', () => {
|
||||
const releaseHost = {
|
||||
id: 17,
|
||||
name: 'ecs-qwen-hz1-17',
|
||||
labels: ['self-hosted', 'Linux', 'X64', 'ecs-qwen-release-host'].map(
|
||||
(n) => ({ name: n }),
|
||||
),
|
||||
status: 'online',
|
||||
};
|
||||
assert.equal(isManagedRunner(releaseHost, FLEET), false);
|
||||
});
|
||||
});
|
||||
|
||||
it('switches online hz1-hz6 runners, including busy runners', () => {
|
||||
const runners = Array.from({ length: 32 }, (_, i) =>
|
||||
runner(i + 1, ['ecs-qwen', 'ecs-autofix', 'diagnostic'], {
|
||||
@@ -30,7 +99,7 @@ describe('review runner schedule', () => {
|
||||
status: i < 2 ? 'offline' : 'online',
|
||||
}),
|
||||
);
|
||||
const actions = planLabels(runners, 'review');
|
||||
const actions = plan(runners, 'review');
|
||||
const onlineRunners = runners.filter(({ status }) => status === 'online');
|
||||
assert.equal(actions.length, 30);
|
||||
for (const [i, action] of actions.entries()) {
|
||||
@@ -49,7 +118,7 @@ describe('review runner schedule', () => {
|
||||
|
||||
it('returns every online review runner to CI and leaves unrelated runners alone', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
plan(
|
||||
[
|
||||
runner(1, ['ecs-review', 'diagnostic']),
|
||||
runner(2, ['ecs-autofix'], { name: 'ecs-qwen-hz1-2' }),
|
||||
@@ -78,19 +147,16 @@ describe('review runner schedule', () => {
|
||||
|
||||
it('is idempotent and rejects invalid modes', () => {
|
||||
assert.deepEqual(
|
||||
planLabels([runner(1, ['ecs-review', 'diagnostic'])], 'review'),
|
||||
plan([runner(1, ['ecs-review', 'diagnostic'])], 'review'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(planLabels([runner(1, ['ecs-qwen'])], 'ci'), []);
|
||||
assert.throws(
|
||||
() => planLabels([], 'invalid'),
|
||||
/mode must be review, day or ci/,
|
||||
);
|
||||
assert.deepEqual(plan([runner(1, ['ecs-qwen'])], 'ci'), []);
|
||||
assert.throws(() => plan([], 'invalid'), /mode must be review, day or ci/);
|
||||
});
|
||||
|
||||
it('keeps the first N runners on review and returns the rest to CI in day mode', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
plan(
|
||||
[
|
||||
runner(1, ['ecs-qwen', 'ecs-autofix'], { name: 'ecs-qwen-hz2-1' }),
|
||||
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hz2-2' }),
|
||||
@@ -130,7 +196,7 @@ describe('review runner schedule', () => {
|
||||
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hz1-2' }),
|
||||
runner(1, ['ecs-qwen'], { name: 'ecs-qwen-hz1-1' }),
|
||||
];
|
||||
const actions = planLabels(runners, 'day', 2);
|
||||
const actions = plan(runners, 'day', 2);
|
||||
// Numeric sort puts hz1-1, hz1-2 in the keep set; hz1-10 falls outside
|
||||
// and would go to CI — but it already has the CI label, so no action.
|
||||
assert.deepEqual(actions, [
|
||||
@@ -149,34 +215,44 @@ describe('review runner schedule', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('is idempotent for day mode and rejects a count above the online fleet', () => {
|
||||
it('truncates to the online fleet when dayCount exceeds it, and reports the pick', () => {
|
||||
const result = planLabels(
|
||||
[runner(1, ['ecs-qwen']), runner(2, ['ecs-qwen'])],
|
||||
'day',
|
||||
5,
|
||||
FLEET,
|
||||
);
|
||||
// Truncation, not failure: every online managed runner goes on review
|
||||
// and the summary flags the gap for the operator.
|
||||
assert.equal(result.truncated, true);
|
||||
assert.equal(result.requestedDayCount, 5);
|
||||
assert.equal(result.effectiveDayCount, 2);
|
||||
assert.equal(result.onlineManaged, 2);
|
||||
assert.deepEqual(result.keptNames.sort(), [
|
||||
'ecs-qwen-hz2-1',
|
||||
'ecs-qwen-hz2-2',
|
||||
]);
|
||||
assert.equal(result.actions.length, 2);
|
||||
});
|
||||
|
||||
it('is idempotent for day mode and validates the count', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
[runner(1, ['ecs-review'], { name: 'ecs-qwen-hz2-1' })],
|
||||
'day',
|
||||
1,
|
||||
),
|
||||
plan([runner(1, ['ecs-review'], { name: 'ecs-qwen-hz2-1' })], 'day', 1),
|
||||
[],
|
||||
);
|
||||
// A stale var would otherwise silently shrink the daytime pool on every
|
||||
// morning tick while the run stays green.
|
||||
assert.throws(
|
||||
() => planLabels([runner(1, ['ecs-qwen'])], 'day', 5),
|
||||
/exceeds online fleet of 1/,
|
||||
);
|
||||
assert.throws(
|
||||
() => planLabels([runner(1, ['ecs-qwen'])], 'day', 0),
|
||||
() => plan([runner(1, ['ecs-qwen'])], 'day', 0),
|
||||
/needs a positive integer review-day runner count/,
|
||||
);
|
||||
assert.throws(
|
||||
() => planLabels([runner(1, ['ecs-qwen'])], 'day', Number.NaN),
|
||||
() => plan([runner(1, ['ecs-qwen'])], 'day', Number.NaN),
|
||||
/needs a positive integer review-day runner count/,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips offline runners when picking the day pool', () => {
|
||||
assert.deepEqual(
|
||||
planLabels(
|
||||
plan(
|
||||
[
|
||||
runner(1, ['ecs-review'], {
|
||||
name: 'ecs-qwen-hz1-1',
|
||||
@@ -202,6 +278,45 @@ describe('review runner schedule', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes ARM64-only and reserved-label hosts from the day pool', () => {
|
||||
const arm = (id) => ({
|
||||
id,
|
||||
name: `ecs-qwen-hz1-${id}`,
|
||||
labels: ['self-hosted', 'Linux', 'ARM64', 'ecs-qwen'].map((n) => ({
|
||||
name: n,
|
||||
})),
|
||||
status: 'online',
|
||||
});
|
||||
const releaseHost = {
|
||||
id: 99,
|
||||
name: 'ecs-qwen-hz1-99',
|
||||
labels: ['self-hosted', 'Linux', 'X64', 'ecs-qwen-release-host'].map(
|
||||
(n) => ({ name: n }),
|
||||
),
|
||||
status: 'online',
|
||||
};
|
||||
const x64 = (id) =>
|
||||
runner(id, ['ecs-qwen'], { name: `ecs-qwen-hz1-${id}` });
|
||||
// Sorted: hz1-1 (arm, skipped), hz1-2 (x64), hz1-3 (x64), hz1-17 (arm,
|
||||
// skipped), hz1-99 (release-host, skipped). Day count 2 picks the two
|
||||
// usable x64 hosts.
|
||||
const result = planLabels(
|
||||
[arm(1), x64(2), x64(3), arm(17), releaseHost],
|
||||
'day',
|
||||
2,
|
||||
FLEET,
|
||||
);
|
||||
assert.deepEqual(result.keptNames.sort(), [
|
||||
'ecs-qwen-hz1-2',
|
||||
'ecs-qwen-hz1-3',
|
||||
]);
|
||||
assert.equal(result.onlineManaged, 2);
|
||||
assert.equal(result.truncated, false);
|
||||
// Reserved-label and ARM-only hosts must not be touched at all.
|
||||
const ids = result.actions.map((a) => a.id).sort();
|
||||
assert.deepEqual(ids, [2, 3]);
|
||||
});
|
||||
|
||||
it('wires two daily UTC switches and manual pool selection', () => {
|
||||
const schedule = readFileSync(
|
||||
new URL('../workflows/qwen-review-runner-schedule.yml', import.meta.url),
|
||||
@@ -225,14 +340,20 @@ describe('review runner schedule', () => {
|
||||
assert.ok(schedule.includes('secrets.RUNNER_ADMIN_PAT'));
|
||||
// The daytime pool. A configured count of runners stay on review after
|
||||
// the 05:00 switch; an unset override falls back to 40, and a count that
|
||||
// exceeds the online fleet fails the tick instead of shrinking the pool
|
||||
// silently.
|
||||
// exceeds the online fleet truncates with a warning rather than
|
||||
// silently shrinking the pool or failing the whole tick.
|
||||
assert.ok(schedule.includes("- 'day'"));
|
||||
assert.match(
|
||||
schedule,
|
||||
/REVIEW_DAY_RUNNER_COUNT: .*\$\{\{ vars\.QWEN_REVIEW_DAY_RUNNER_COUNT \|\| '40' \}\}/,
|
||||
);
|
||||
assert.ok(schedule.includes('"$REVIEW_DAY_RUNNER_COUNT"'));
|
||||
// The fleet pattern is configurable so a region change is a var edit,
|
||||
// not a code change.
|
||||
assert.match(
|
||||
schedule,
|
||||
/QWEN_REVIEW_FLEET_NAME_PATTERN: .*\$\{\{ vars\.QWEN_REVIEW_FLEET_NAME_PATTERN/,
|
||||
);
|
||||
// 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
|
||||
@@ -264,13 +385,18 @@ if (!args.includes('--method')) {
|
||||
);
|
||||
};
|
||||
|
||||
const spawnPlanner = (dir, runners, { fail = false } = {}) =>
|
||||
const spawnPlanner = (
|
||||
dir,
|
||||
runners,
|
||||
{ fail = false, mode = 'review', dayCount = '' } = {},
|
||||
) =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(new URL('./review-runner-schedule.mjs', import.meta.url)),
|
||||
'example/repo',
|
||||
'review',
|
||||
mode,
|
||||
...(mode === 'day' ? [dayCount] : []),
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
@@ -319,7 +445,7 @@ it(
|
||||
);
|
||||
|
||||
it(
|
||||
'fails loudly when no hz runner exists instead of switching nothing',
|
||||
'fails loudly when no managed runner exists instead of switching nothing',
|
||||
{ skip: process.platform === 'win32' },
|
||||
() => {
|
||||
// Without the guard a renamed or empty fleet exits 0 after reporting
|
||||
@@ -342,7 +468,7 @@ it(
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(
|
||||
result.stderr,
|
||||
/::error::no ecs-qwen-hz\[1-6\]-<n> runner found/,
|
||||
/::error::no runner matches QWEN_REVIEW_FLEET_NAME_PATTERN/,
|
||||
);
|
||||
assert.equal(readFileSync(log, 'utf8'), '');
|
||||
}
|
||||
@@ -351,3 +477,103 @@ it(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'day mode end-to-end: applies keep-set labels and writes a kept-names summary',
|
||||
{ skip: process.platform === 'win32' },
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'runner-switch-'));
|
||||
try {
|
||||
const log = join(dir, 'calls');
|
||||
writeFakeGh(dir);
|
||||
writeFileSync(log, '');
|
||||
const summaryPath = join(dir, 'summary.md');
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(
|
||||
new URL('./review-runner-schedule.mjs', import.meta.url),
|
||||
),
|
||||
'example/repo',
|
||||
'day',
|
||||
'1',
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: dir + delimiter + process.env.PATH,
|
||||
RUNNER_ADMIN_TOKEN: 'test-only',
|
||||
GITHUB_STEP_SUMMARY: summaryPath,
|
||||
PROBE_LOG: log,
|
||||
PROBE_FAIL: 'false',
|
||||
PROBE_RUNNERS: JSON.stringify([
|
||||
runner(1, ['ecs-qwen'], { name: 'ecs-qwen-hz1-1' }),
|
||||
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hz1-2' }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
// hz1-1 is the pick: POST ecs-review then DELETE ecs-qwen.
|
||||
// hz1-2 already carries ecs-qwen and stays on CI: no calls.
|
||||
assert.deepEqual(readFileSync(log, 'utf8').trim().split('\n'), [
|
||||
'POST',
|
||||
'DELETE',
|
||||
]);
|
||||
const summary = readFileSync(summaryPath, 'utf8');
|
||||
assert.match(summary, /keeping on review \(1\):/);
|
||||
assert.match(summary, /ecs-qwen-hz1-1/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'day mode rejects a non-numeric count before touching the API',
|
||||
{ skip: process.platform === 'win32' },
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'runner-switch-'));
|
||||
try {
|
||||
const log = join(dir, 'calls');
|
||||
writeFakeGh(dir);
|
||||
writeFileSync(log, '');
|
||||
// `parseInt` would accept each of these as `4` — the strict regex
|
||||
// rejects them so a typo cannot silently shrink the daytime pool.
|
||||
for (const bad of ['4O', '4.5', '4 0', '1e2', 'abc', '']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(
|
||||
new URL('./review-runner-schedule.mjs', import.meta.url),
|
||||
),
|
||||
'example/repo',
|
||||
'day',
|
||||
bad,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: dir + delimiter + process.env.PATH,
|
||||
RUNNER_ADMIN_TOKEN: 'test-only',
|
||||
GITHUB_STEP_SUMMARY: '',
|
||||
PROBE_LOG: log,
|
||||
PROBE_FAIL: 'false',
|
||||
PROBE_RUNNERS: '[]',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 1, `expected ${bad} to fail`);
|
||||
assert.match(
|
||||
result.stderr,
|
||||
/needs a positive integer review-day runner count/,
|
||||
);
|
||||
}
|
||||
assert.equal(readFileSync(log, 'utf8'), '');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -500,8 +500,9 @@ jobs:
|
||||
# (default: 360); must stay above QWEN_REVIEW_MAX_TIMEOUT_MINUTES so
|
||||
# retry plus posting never hit the cap.
|
||||
timeout-minutes: '${{ fromJSON(vars.QWEN_REVIEW_JOB_TIMEOUT_MINUTES) }}'
|
||||
# The schedule switches hk1/hk2 to `ecs-review` at 17:00 Asia/Shanghai
|
||||
# and back to CI at 05:00. Reviews queue while the pool is closed.
|
||||
# The schedule switches the managed fleet to `ecs-review` at 17:00
|
||||
# Asia/Shanghai and back to CI at 05:00. Reviews queue while the pool
|
||||
# is closed.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-review"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
name: 'Qwen Review Runner Schedule'
|
||||
|
||||
# Switch every online hz1-hz6 runner at 17:00 / 05:00 Asia/Shanghai.
|
||||
# 17:00 puts the whole fleet on review; 05:00 returns it to CI but keeps
|
||||
# QWEN_REVIEW_DAY_RUNNER_COUNT runners on review, so that many reviews can
|
||||
# start during the daytime instead of queueing until the evening. GitHub
|
||||
# cron uses UTC.
|
||||
# Switch the managed runner fleet between review and CI at 17:00 / 05:00
|
||||
# Asia/Shanghai. 17:00 puts the whole fleet on review; 05:00 returns it to
|
||||
# CI but keeps QWEN_REVIEW_DAY_RUNNER_COUNT runners on review, so that many
|
||||
# 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.
|
||||
#
|
||||
# QWEN_REVIEW_DAY_RUNNER_COUNT is the number of online hz runners that stay
|
||||
# on `ecs-review` in the `day` pool. Unset falls back to 40; the script fails
|
||||
# loudly if the count exceeds the online fleet, so a stale var cannot
|
||||
# silently shrink the daytime pool.
|
||||
# QWEN_REVIEW_DAY_RUNNER_COUNT is the number of online managed runners that
|
||||
# stay on `ecs-review` in the `day` pool. Unset falls back to 40; if the
|
||||
# count exceeds the online fleet the pool truncates with a warning rather
|
||||
# than failing the whole tick.
|
||||
#
|
||||
# QWEN_REVIEW_FLEET_NAME_PATTERN is the regex source that decides which
|
||||
# runners this schedule manages. Unset falls back to `^ecs-qwen-hz[1-6]-\d+$`.
|
||||
# Change it to onboard or retire a region without touching the script.
|
||||
#
|
||||
# RUNNER_ADMIN_PAT needs runner-label write access: repo scope for a
|
||||
# classic PAT, or repository Administration: write for a fine-grained PAT.
|
||||
@@ -25,7 +29,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pool:
|
||||
description: 'Pool for all online hk1/hk2 runners'
|
||||
description: 'Pool for the managed runner fleet'
|
||||
type: 'choice'
|
||||
required: true
|
||||
default: 'ci'
|
||||
@@ -71,5 +75,6 @@ jobs:
|
||||
RUNNER_ADMIN_TOKEN: '${{ secrets.RUNNER_ADMIN_PAT }}'
|
||||
POOL: "${{ github.event_name == 'workflow_dispatch' && inputs.pool || (github.event.schedule == '0 9 * * *' && 'review' || 'day') }}"
|
||||
REVIEW_DAY_RUNNER_COUNT: "${{ vars.QWEN_REVIEW_DAY_RUNNER_COUNT || '40' }}"
|
||||
QWEN_REVIEW_FLEET_NAME_PATTERN: "${{ vars.QWEN_REVIEW_FLEET_NAME_PATTERN || '^ecs-qwen-hz[1-6]-\\d+$' }}"
|
||||
run: |
|
||||
node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL" "$REVIEW_DAY_RUNNER_COUNT"
|
||||
Reference in new issue
Block a user