diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 151f2c752c3..45663575f95 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -17,6 +17,9 @@ const DEFAULT_PREFLIGHT_RUN_TIMEOUT_MS = 60_000; const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json"); const DEFAULT_PROFILE = "all"; const RELEASE_PATH_PROFILE = "release-path"; +const IS_MAIN = process.argv[1] + ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; const LIVE_PROFILE_TIMEOUT_MS = 20 * 60 * 1000; const LIVE_CLI_TIMEOUT_MS = 20 * 60 * 1000; const LIVE_ACP_TIMEOUT_MS = 20 * 60 * 1000; @@ -617,6 +620,32 @@ function laneResources(poolLane) { return ["docker", ...(poolLane.resources ?? [])]; } +export function describeDockerSchedulerLimits(parallelism, options) { + return `parallelism=${parallelism} weightLimit=${options.weightLimit} resources=${resourceLimitsSummary( + options.resourceLimits, + )}`; +} + +export function canStartSchedulerLane(candidate, active, parallelism, options) { + const weight = laneWeight(candidate); + if (active.count >= parallelism) { + return false; + } + + const exceedsWeightLimit = active.weight + weight > options.weightLimit; + const exceedsResourceLimit = laneResources(candidate).some((resource) => { + const limit = options.resourceLimits[resource] ?? options.weightLimit; + const current = active.resources.get(resource) ?? 0; + return current + weight > limit; + }); + + if (!exceedsWeightLimit && !exceedsResourceLimit) { + return true; + } + + return active.count === 0; +} + function laneSummary(poolLane) { const resources = laneResources(poolLane).join(","); const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : ""; @@ -1135,18 +1164,7 @@ async function runLanePool(poolLanes, baseEnv, logDir, parallelism, options) { } function canStartLane(candidate) { - const weight = laneWeight(candidate); - if (active.count >= parallelism || active.weight + weight > options.weightLimit) { - return false; - } - for (const resource of laneResources(candidate)) { - const limit = options.resourceLimits[resource] ?? options.weightLimit; - const current = active.resources.get(resource) ?? 0; - if (current + weight > limit) { - return false; - } - } - return true; + return canStartSchedulerLane(candidate, active, parallelism, options); } function reserve(candidate) { @@ -1207,7 +1225,12 @@ async function runLanePool(poolLanes, baseEnv, logDir, parallelism, options) { } if (running.size === 0) { const blocked = pending.map(laneSummary).join(", "); - throw new Error(`No Docker lanes fit scheduler limits: ${blocked}`); + throw new Error( + `No Docker lanes fit scheduler limits (${describeDockerSchedulerLimits( + parallelism, + options, + )}): ${blocked}. Tune OPENCLAW_DOCKER_ALL_PARALLELISM, OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT, or OPENCLAW_DOCKER_ALL__LIMIT.`, + ); } const { promise, result } = await Promise.race(running); @@ -1549,7 +1572,9 @@ async function main() { console.log("==> Docker test suite passed"); } -await main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); +if (IS_MAIN) { + await main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts new file mode 100644 index 00000000000..28f0856f1ca --- /dev/null +++ b/test/scripts/docker-all-scheduler.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + canStartSchedulerLane, + describeDockerSchedulerLimits, +} from "../../scripts/test-docker-all.mjs"; + +const limits = { + resourceLimits: { + docker: 2, + npm: 2, + }, + weightLimit: 2, +}; + +function activePool({ + count = 0, + resources = {}, + weight = 0, +}: { + count?: number; + resources?: Record; + weight?: number; +} = {}) { + return { + count, + resources: new Map(Object.entries(resources)), + weight, + }; +} + +describe("scripts/test-docker-all scheduler", () => { + it("allows an overweight lane to start alone under low parallelism", () => { + expect( + canStartSchedulerLane( + { + name: "install-e2e", + resources: ["npm"], + weight: 4, + }, + activePool(), + 2, + limits, + ), + ).toBe(true); + }); + + it("does not co-schedule another lane while an overweight lane is active", () => { + expect( + canStartSchedulerLane( + { + name: "package-update", + resources: ["npm"], + weight: 1, + }, + activePool({ + count: 1, + resources: { + docker: 4, + npm: 4, + }, + weight: 4, + }), + 2, + limits, + ), + ).toBe(false); + }); + + it("preserves the parallelism count cap", () => { + expect( + canStartSchedulerLane( + { + name: "package-update", + resources: ["npm"], + weight: 1, + }, + activePool({ + count: 2, + resources: { + docker: 1, + npm: 1, + }, + weight: 1, + }), + 2, + limits, + ), + ).toBe(false); + }); + + it("keeps resource and weight limits as co-scheduling limits", () => { + expect( + canStartSchedulerLane( + { + name: "npm-smoke", + resources: ["npm"], + weight: 1, + }, + activePool({ + count: 1, + resources: { + docker: 1, + npm: 1, + }, + weight: 1, + }), + 2, + limits, + ), + ).toBe(true); + + expect( + canStartSchedulerLane( + { + name: "npm-heavy", + resources: ["npm"], + weight: 2, + }, + activePool({ + count: 1, + resources: { + docker: 1, + npm: 1, + }, + weight: 1, + }), + 2, + limits, + ), + ).toBe(false); + }); + + it("describes effective scheduler limits for operator errors", () => { + expect(describeDockerSchedulerLimits(2, limits)).toBe( + "parallelism=2 weightLimit=2 resources=docker=2 npm=2", + ); + }); +});