diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31d6e99240c1..08a514e2e70b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,12 @@ jobs: # macos_pr_owned_jobs (admission, shards, lag, cli-product) take it in # place of the pool label, on attempt 1 and after a refusal. macos_pr_root_runner: ${{ steps.macos-pool.outputs.root_runner }} + # Set only beside a root runner, when the pool has machines beyond its + # root runners: glaeda-side--xcode-, the minis' other + # runners. The side lanes in macos_pr_owned_jobs (claude-wrapper, + # remote-daemon) take it in place of the pool label, so they never hold + # a root runner. + macos_pr_side_runner: ${{ steps.macos-pool.outputs.side_runner }} # Set only beside a root runner, when an idle one carries the warm label # of this run's merge base: ["", "glaeda-warm-"], # the labels compile admission's attempt 1 takes instead. @@ -1090,6 +1096,7 @@ jobs: pr_retry_runner: ${{ needs.changes.outputs.macos_pr_retry_runner }} pr_owned_jobs: ${{ needs.changes.outputs.macos_pr_owned_jobs }} pr_refused_retry_runner: ${{ needs.changes.outputs.macos_pr_refused_retry_runner }} + pr_side_runner: ${{ needs.changes.outputs.macos_pr_side_runner }} native_tests: ${{ needs.changes.outputs.remote_daemon_native == 'true' || contains(github.event.pull_request.labels.*.name, 'full-ci') }} web: @@ -1106,7 +1113,7 @@ jobs: name: Claude wrapper regressions needs: [changes, static-preflight] if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.changes.outputs.claude_wrapper == 'true' || (needs.changes.outputs.macos == 'true' && needs.changes.outputs.full_suite == 'true')) }} - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (startsWith(needs.changes.outputs.macos_pr_runner, 'blacksmith-') && needs.changes.outputs.macos_pr_runner || 'blacksmith-6vcpu-macos-15') || github.event_name == 'pull_request' && github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(needs.changes.outputs.macos_pr_owned_jobs, ' claude-wrapper ') && needs.changes.outputs.macos_pr_refused_retry_runner || github.event_name == 'pull_request' && (github.run_attempt > 1 || !contains(needs.changes.outputs.macos_pr_owned_jobs, ' claude-wrapper ')) && needs.changes.outputs.macos_pr_retry_runner || github.event_name == 'pull_request' && (needs.changes.outputs.macos_pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (startsWith(needs.changes.outputs.macos_pr_runner, 'blacksmith-') && needs.changes.outputs.macos_pr_runner || 'blacksmith-6vcpu-macos-15') || github.event_name == 'pull_request' && github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(needs.changes.outputs.macos_pr_owned_jobs, ' claude-wrapper ') && (needs.changes.outputs.macos_pr_side_runner || needs.changes.outputs.macos_pr_refused_retry_runner) || github.event_name == 'pull_request' && (github.run_attempt > 1 || !contains(needs.changes.outputs.macos_pr_owned_jobs, ' claude-wrapper ')) && needs.changes.outputs.macos_pr_retry_runner || github.event_name == 'pull_request' && (needs.changes.outputs.macos_pr_side_runner || needs.changes.outputs.macos_pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }} timeout-minutes: 10 steps: - name: Checkout wrapper and test inputs diff --git a/.github/workflows/remote-daemon.yml b/.github/workflows/remote-daemon.yml index a140fec092b7..b2d99aeaca1b 100644 --- a/.github/workflows/remote-daemon.yml +++ b/.github/workflows/remote-daemon.yml @@ -38,6 +38,14 @@ on: required: false default: "" type: string + # Set only when pr_runner is persistent with a root count and machines + # beyond its root runners: the pool's side label + # (glaeda-side--xcode-). The macOS tests take it in + # place of pr_runner, so they never hold a mini's root runner. + pr_side_runner: + required: false + default: "" + type: string push: branches: [main] paths: @@ -111,7 +119,7 @@ jobs: # Plain `go test` with no Xcode or GUI: any Mac will do. Follow the same # lanes as the other pull-request macOS jobs instead of pinning the # contended macOS 26 pool. - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (startsWith(inputs.pr_runner, 'blacksmith-') && inputs.pr_runner || 'blacksmith-6vcpu-macos-15') || github.event_name == 'pull_request' && (github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(inputs.pr_owned_jobs, ' remote-daemon ') && inputs.pr_refused_retry_runner || (github.run_attempt > 1 || !contains(inputs.pr_owned_jobs, ' remote-daemon ')) && inputs.pr_retry_runner || inputs.pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (startsWith(inputs.pr_runner, 'blacksmith-') && inputs.pr_runner || 'blacksmith-6vcpu-macos-15') || github.event_name == 'pull_request' && (github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(inputs.pr_owned_jobs, ' remote-daemon ') && (inputs.pr_side_runner || inputs.pr_refused_retry_runner) || (github.run_attempt > 1 || !contains(inputs.pr_owned_jobs, ' remote-daemon ')) && inputs.pr_retry_runner || inputs.pr_side_runner || inputs.pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/scripts/ci/pr_runner_pool.py b/scripts/ci/pr_runner_pool.py index 16f3bc68746a..2e283dbd91aa 100644 --- a/scripts/ci/pr_runner_pool.py +++ b/scripts/ci/pr_runner_pool.py @@ -126,9 +126,18 @@ a root count sends its placed root jobs (ROOT_JOBS) to the `root_runner` output, and place() puts no more of them there than its root runners have room for, by the same expected wait; a -pool without one keeps the pool label for every job. The side lanes keep the -pool label either way. A root job also holds one of the pool's machines, so -it counts against both. +pool without one keeps the pool label for every job. A root job also holds +one of the pool's machines, so it counts against both. + +Side lanes (the Claude wrapper and remote daemon lanes, light jobs that never +touch a canonical root) take the pool's side label, +`glaeda-side--xcode-` (side_label()), the other runners of +each mini, whenever the pool has a root count and more machines than root +runners (the `side_runner` output). On the pool label a side lane landed on a +root runner about half the time (11 of 21 on 2026-09-25, 06:30 to 09:00Z, +3,300 s of root-runner time) and kept a compile or product consumer off that +mini's root while it ran; on cmux7s and cmux9s, with one root, it blocked the +mini's only compile. A pool without a root count keeps the pool label. Warm affinity: an owned Mac keeps compile admission's DerivedData (owned_build_state.py), and ci-owned-warm-labels.yml labels its root runner @@ -244,8 +253,8 @@ RUN_CLASSES = ("std", "light") # `glaeda-root-...` is the one runner per mini that may take a root job (ROOT_JOBS). # `glaeda-side-...` are the other runners: the light side-lane workflows take it -# (vars.CI_SIDE_LANE_RUNNER, owned_pool_rescue.SIDE_WORKFLOW_PATHS). No picker -# routes to it, but its jobs hold its pool's machines. +# (vars.CI_SIDE_LANE_RUNNER, owned_pool_rescue.SIDE_WORKFLOW_PATHS), and so do +# this picker's side lanes (side_runner()). Its jobs hold its pool's machines. OWNED_LABEL = re.compile(r"glaeda-(?:root-|side-)?(?:xl|std|light)-xcode-[0-9]+(?:\.[0-9]+)*") ROOT_PREFIX = "glaeda-root-" SIDE_PREFIX = "glaeda-side-" @@ -387,6 +396,27 @@ def root_label(label: str) -> str: return ROOT_PREFIX + label.removeprefix("glaeda-") +def side_label(label: str) -> str: + """The side runners' label for an owned pool label, or "" for any other label.""" + if not persistent(label) or label.startswith((ROOT_PREFIX, SIDE_PREFIX)): + return "" + return SIDE_PREFIX + label.removeprefix("glaeda-") + + +def side_runner(choice: "Choice", owned_slots: Mapping[str, int]) -> str: + """The label a pick's side lanes take: the pool's side label, or "" to keep the pool label. + + Only on a pool with a root count (the root and side runners are split), + and only while CI_OWNED_POOL_SLOTS leaves it machines beyond its root + runners, so a side lane never waits on a label no runner carries. + """ + if not choice.root_runner or not persistent(choice.runner): + return "" + if owned_slots.get(choice.runner, 0) <= owned_slots.get(choice.root_runner, 0): + return "" + return side_label(choice.runner) + + def pool_label(label: str) -> str: """The owned pool a root or side label's runners belong to; any other label unchanged.""" for prefix in (ROOT_PREFIX, SIDE_PREFIX): @@ -515,6 +545,8 @@ def run_jobs(**routing: str | None) -> int: # glaeda's canonical-root jobs: admission and every job after it (RunJobs.after: # the shards, tests-build-and-lag, cli-product-tests). The side lanes are not. ROOT_JOBS = "admission, shards, lag, cli-product" +# The side lanes (RunJobs.side): light, no canonical root; they take side_runner() on a pool with a root count. +SIDE_LANE_JOBS = ("claude-wrapper", "remote-daemon") def gui_job(key: str) -> bool: @@ -704,8 +736,9 @@ def _slots(raw: str | None, pr_xcode_app: str | None = None) -> tuple[dict[str, if not isinstance(count, int) or isinstance(count, bool) or count <= 0: problems.append(f"{SLOTS_VARIABLE} entry {label!r} has {count!r} machines, not a positive whole number") elif label.startswith(("side-", SIDE_PREFIX)): - # No picker routes to side runners (vars.CI_SIDE_LANE_RUNNER does), so a count is a mistake. - problems.append(f"{SLOTS_VARIABLE} entry {label!r} names side runners, which take no picked run") + # Side runners are a pool's machines less its root runners (side_runner()), so a count is a mistake. + problems.append(f"{SLOTS_VARIABLE} entry {label!r} names side runners, which are counted " + "as the pool's machines less its root runners") elif label in CAPABILITY_LABELS: continue elif persistent(label): @@ -1565,7 +1598,7 @@ def pull_request_runs_since(self, since: str, *, exclude_run_id: int | None) -> def summary(choice: Choice, snapshot: Mapping[str, Any] | None, *, now: dt.datetime, owned_slots: Mapping[str, int] | None = None, problems: Sequence[str] = (), - owned_jobs: Sequence[str] = (), admission_runner: str = "") -> str: + owned_jobs: Sequence[str] = (), admission_runner: str = "", side: str = "") -> str: runner = choice.runner or "each job's default (MACOS_RUNNER_PR or its fallback)" lines = ["### macOS pool for this run", "", f"- Pool: `{runner}`", f"- Why: {choice.reason}"] if choice.xcode_app: @@ -1575,6 +1608,8 @@ def summary(choice: Choice, snapshot: Mapping[str, Any] | None, *, now: dt.datet f"and a re-run of failed jobs, goes to: `{choice.retry_runner}`") if choice.root_runner: lines.append(f"- Root jobs among them ({ROOT_JOBS}) take `{choice.root_runner}`") + if side: + lines.append(f"- Side lanes among them ({', '.join(SIDE_LANE_JOBS)}) take `{side}`") if admission_runner: labels = " + ".join(f"`{label}`" for label in json.loads(admission_runner)) lines.append(f"- Compile admission takes {labels}: an idle root runner kept a build of this run's merge base") @@ -1704,8 +1739,10 @@ def count_routed(since: str) -> int: if (env.get("WARM_LABELS") == "1" and choice.root_runner and ADMISSION_JOB in owned_jobs and live_runners is not None): admission_runner = warm_admission_runner(live_runners, choice.root_runner, env.get("MERGED_ONTO")) - text = summary(choice, snapshot, now=now, owned_slots=slots(env.get("OWNED_SLOTS"), pr_xcode_app), problems=problems, - owned_jobs=owned_jobs, admission_runner=admission_runner) + owned_slots = slots(env.get("OWNED_SLOTS"), pr_xcode_app) + side = side_runner(choice, owned_slots) + text = summary(choice, snapshot, now=now, owned_slots=owned_slots, problems=problems, + owned_jobs=owned_jobs, admission_runner=admission_runner, side=side) print(text) if env.get("GITHUB_STEP_SUMMARY"): with open(env["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle: @@ -1722,6 +1759,9 @@ def count_routed(since: str) -> int: # What the root jobs in owned_jobs take instead of # the pool label, on attempt 1 and on that attempt 2. f"root_runner={choice.root_runner}\n" + # What the side lanes in owned_jobs take instead of + # the pool label, on attempt 1 and on that attempt 2. + f"side_runner={side}\n" # JSON labels for admission's attempt 1: the root label # and the warm label of this run's merge base, or "". f"admission_runner={admission_runner}\n" diff --git a/scripts/ci/queue_janitor.py b/scripts/ci/queue_janitor.py index 6ab958c499f3..a01c572b43fa 100644 --- a/scripts/ci/queue_janitor.py +++ b/scripts/ci/queue_janitor.py @@ -85,7 +85,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from pr_runner_pool import MAX_RUN_JOBS # noqa: E402 from pr_runner_pool import persistent as owned_pool # noqa: E402 -from pr_runner_pool import CAPABILITY_LABELS, pool_label, root_label # noqa: E402 +from pr_runner_pool import CAPABILITY_LABELS, pool_label, root_label, side_label # noqa: E402 API = "https://api.github.com" @@ -293,15 +293,16 @@ def marker_peaks(marker: tuple[str, int], owned_jobs: Sequence[Mapping[str, Any] ci.yml's marker names the pool label and every owned machine the run placed, root jobs and side lanes alike. Its root jobs' share is that peak - less the jobs it put on the pool label itself (the side lanes, which - start beside admission); a side lane not listed yet only reserves more. + less the jobs it put on the pool label itself or on its side label (the + side lanes, which start beside admission and take the side label when + the picker named one); a side lane not listed yet only reserves more. An E2E marker names the root label when the run took one, which is also one of the pool's machines. """ pool, peak = marker if pool_label(pool) != pool: return [(pool, peak), (pool_label(pool), peak)] - side = sum(1 for job in owned_jobs if owned_label(job) == pool) + side = sum(1 for job in owned_jobs if owned_label(job) in (pool, side_label(pool))) return [(pool, peak)] + ([(root_label(pool), peak - side)] if root_label(pool) and peak > side else []) diff --git a/tests/test_ci_pr_runner_pool.py b/tests/test_ci_pr_runner_pool.py index 2dabee524505..6e56cd1c4abe 100644 --- a/tests/test_ci_pr_runner_pool.py +++ b/tests/test_ci_pr_runner_pool.py @@ -290,7 +290,8 @@ def test_main_writes_outputs_and_summary(self): sys.stdout = old self.assertEqual(out.read_text(), f"runner={LARGE}\nxcode_app=\npersistent=false\n" f"retry_runner=\njobs={pool.MAX_RUN_JOBS}\nshard_runner=\n" - f"refused_retry_runner=\nroot_runner=\nadmission_runner=\nowned_jobs=\n") + f"refused_retry_runner=\nroot_runner=\nside_runner=\n" + "admission_runner=\nowned_jobs=\n") text = summary.read_text() self.assertIn(f"Pool: `{LARGE}`", text) self.assertIn(f"{SMALL}: 21 queued, 10 running", text) @@ -462,6 +463,14 @@ def root_lane(key: str) -> str: "|| inputs.pr_root_runner || inputs.pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15'") +def side_lane(key: str) -> str: + """retry_lane() for a side lane: the side label, when the picker named one, before the pool label.""" + return (f"github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(inputs.pr_owned_jobs, {key}) " + "&& (inputs.pr_side_runner || inputs.pr_refused_retry_runner) " + f"|| (github.run_attempt > 1 || !contains(inputs.pr_owned_jobs, {key})) && inputs.pr_retry_runner " + "|| inputs.pr_side_runner || inputs.pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15'") + + def warm_lane(index: str = "") -> str: """root_lane() for compile admission: attempt 1 may take the warm labels first. @@ -1118,6 +1127,7 @@ def test_main_names_the_owned_jobs_and_marks_their_peak(self): ROOT_MINI = "glaeda-root-std-xcode-26.6" +SIDE_MINI = "glaeda-side-std-xcode-26.6" class QueueBehindBusyRunners(unittest.TestCase): @@ -1534,6 +1544,21 @@ def test_live_capacity_reads_idle_root_runners(self): jobs=3, root_jobs=1) self.assertEqual((off.runner, off.root_runner), (MINI, "")) + def test_side_lanes_take_the_side_label_beside_a_root_count(self): + self.assertTrue(pool.persistent(SIDE_MINI)) + self.assertEqual((pool.side_label(MINI), pool.pool_label(SIDE_MINI)), (SIDE_MINI, MINI)) + self.assertEqual((pool.side_label(ROOT_MINI), pool.side_label(SIDE_MINI), pool.side_label(SMALL)), ("", "", "")) + rooted = pool.Choice(MINI, PR_XCODE, "", LARGE, 5, root_runner=ROOT_MINI, root_budget=3) + self.assertEqual(pool.side_runner(rooted, {MINI: 36, ROOT_MINI: 16}), SIDE_MINI) + self.assertEqual(pool.side_runner(pool.Choice(LIGHT, PR_XCODE, "", LARGE, 2, + root_runner=pool.root_label(LIGHT), root_budget=1), + {LIGHT: 4, pool.root_label(LIGHT): 2}), pool.side_label(LIGHT)) + # Every machine a root runner: no side runner carries the label, so the pool label stays. + self.assertEqual(pool.side_runner(rooted, {MINI: 16, ROOT_MINI: 16}), "") + # No root count (root routing off) or a Blacksmith pick: the pool label as before. + self.assertEqual(pool.side_runner(pool.Choice(MINI, PR_XCODE, "", LARGE, 5), {MINI: 36}), "") + self.assertEqual(pool.side_runner(pool.Choice(LARGE, "", ""), {MINI: 36, ROOT_MINI: 16}), "") + def test_no_root_count_keeps_the_pool_label(self): choice = owned_choice(fleet(busy=0), jobs=4, root_jobs=2) self.assertEqual((choice.runner, choice.root_runner), (MINI, "")) @@ -1563,6 +1588,8 @@ def test_main_writes_the_root_runner(self): outputs = dict(line.split("=", 1) for line in out.read_text().splitlines()) self.assertEqual((outputs["runner"], outputs["root_runner"], outputs["refused_retry_runner"]), (MINI, ROOT_MINI, MINI)) + # The side lanes take the side runners: 30 of the 40 machines. + self.assertEqual(outputs["side_runner"], SIDE_MINI) # Three root runners free: admission and two shards, beside every side lane. self.assertEqual(outputs["owned_jobs"], " admission shard-1 shard-2 shard-3 remote-daemon claude-wrapper ") @@ -1578,6 +1605,13 @@ def job(label, status): snap = janitor.pool_load_snapshot([run], jobs, now=NOW, markers={1: (MINI, 7)}) self.assertEqual({key: snap["pools"][ROOT_MINI][key] for key in ("queued", "running", "committed")}, {"queued": 1, "running": 1, "committed": 5}) + self.assertEqual({key: snap["pools"][MINI][key] for key in ("queued", "running", "committed")}, + {"queued": 1, "running": 2, "committed": 7}) + # Side lanes on the side label (side_runner) leave the root share the same. + jobs = {1: [job(ROOT_MINI, "in_progress"), job(ROOT_MINI, "queued"), job(SIDE_MINI, "in_progress"), + job(SIDE_MINI, "completed")]} + snap = janitor.pool_load_snapshot([run], jobs, now=NOW, markers={1: (MINI, 7)}) + self.assertEqual(snap["pools"][ROOT_MINI]["committed"], 5) self.assertEqual({key: snap["pools"][MINI][key] for key in ("queued", "running", "committed")}, {"queued": 1, "running": 2, "committed": 7}) # An E2E marker names the root label: one root runner, one machine. @@ -1719,12 +1753,14 @@ def lanes(self, name): def test_every_pr_route_in_the_run_reads_the_choice(self): expected = { - "ci.yml": "needs.changes.outputs.macos_pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15'", + # The Claude wrapper, a side lane: the side label first. + "ci.yml": "needs.changes.outputs.macos_pr_side_runner || needs.changes.outputs.macos_pr_runner " + "|| vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15'", # Compile admission (and its CMUX_PRODUCT_RUNNER mirror) and # tests-build-and-lag each test their own owned_jobs key, and are # root jobs; the side lanes are not. "ci-macos.yml": {warm_lane(), warm_lane("[0]"), root_lane("' lag '")}, - "remote-daemon.yml": {retry_lane("' remote-daemon '")}, + "remote-daemon.yml": {side_lane("' remote-daemon '")}, } for name, lane in expected.items(): lanes = self.lanes(name) @@ -1741,7 +1777,8 @@ def test_a_rerun_of_failed_shards_leaves_the_owned_pool(self): wrapper = self.workflow("ci.yml")["jobs"]["claude-wrapper"]["runs-on"] self.assertIn("github.event_name == 'pull_request' && github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(" "needs.changes.outputs.macos_pr_owned_jobs, ' claude-wrapper ') && " - "needs.changes.outputs.macos_pr_refused_retry_runner || github.event_name == 'pull_request' && " + "(needs.changes.outputs.macos_pr_side_runner || needs.changes.outputs.macos_pr_refused_retry_runner) " + "|| github.event_name == 'pull_request' && " "(github.run_attempt > 1 || !contains(needs.changes.outputs.macos_pr_owned_jobs, " "' claude-wrapper ')) && needs.changes.outputs.macos_pr_retry_runner", wrapper) @@ -1755,9 +1792,14 @@ def test_callers_pass_the_choice(self): retry = "${{ needs.changes.outputs.macos_pr_retry_runner }}" for name in ("macos", "remote-daemon"): self.assertEqual(jobs[name]["with"]["pr_retry_runner"], retry, name) - # Only ci-macos.yml runs root jobs; the side lanes keep the pool label. + # Only ci-macos.yml runs root jobs; the side lanes take the side label. self.assertEqual(jobs["macos"]["with"]["pr_root_runner"], "${{ needs.changes.outputs.macos_pr_root_runner }}") self.assertNotIn("pr_root_runner", jobs["remote-daemon"]["with"]) + self.assertEqual(jobs["remote-daemon"]["with"]["pr_side_runner"], + "${{ needs.changes.outputs.macos_pr_side_runner }}") + self.assertNotIn("pr_side_runner", jobs["macos"]["with"]) + self.assertEqual(self.workflow("ci.yml")["jobs"]["changes"]["outputs"]["macos_pr_side_runner"], + "${{ steps.macos-pool.outputs.side_runner }}") self.assertEqual(jobs["macos"]["with"]["pr_admission_runner"], "${{ needs.changes.outputs.macos_pr_admission_runner }}") self.assertNotIn("pr_admission_runner", jobs["remote-daemon"]["with"]) diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index eab83ff1de63..215b6b790e89 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -1388,13 +1388,16 @@ ROOT_PICKED = "steps.macos-pool.outputs.root_runner" ROOT_OUTPUT = "needs.changes.outputs.macos_pr_root_runner" ADMISSION_PICKED = "steps.macos-pool.outputs.admission_runner" ADMISSION_OUTPUT = "needs.changes.outputs.macos_pr_admission_runner" +SIDE_PICKED = "steps.macos-pool.outputs.side_runner" +SIDE_OUTPUT = "needs.changes.outputs.macos_pr_side_runner" PASSED = "${{ needs.changes.outputs.macos_pr_runner }}" # Each input the picked pools reach a reusable workflow through, and its value. INPUTS = {"pr_runner": PASSED, "pr_retry_runner": "${{ " + RETRY_OUTPUT + " }}", "pr_refused_retry_runner": "${{ " + REFUSED_OUTPUT + " }}", "pr_shard_runner": "${{ " + SHARD_OUTPUT + " }}", "pr_root_runner": "${{ " + ROOT_OUTPUT + " }}", - "pr_admission_runner": "${{ " + ADMISSION_OUTPUT + " }}"} + "pr_admission_runner": "${{ " + ADMISSION_OUTPUT + " }}", + "pr_side_runner": "${{ " + SIDE_OUTPUT + " }}"} MARKER = ("macos-pool-persistent-${{ github.run_id }}-${{ github.run_attempt }}" "-${{ steps.macos-pool.outputs.jobs }}-${{ steps.macos-pool.outputs.runner }}") # The runs-on branches that may read the picked pool, each behind its @@ -1405,9 +1408,13 @@ GUARDED = ( " || 'blacksmith-6vcpu-macos-15')", "github.event_name == 'pull_request' && (needs.changes.outputs.macos_pr_runner || vars.MACOS_RUNNER_PR" " || 'blacksmith-6vcpu-macos-15')", + # A side lane: the side label of the pool first, when the picker named one. + "github.event_name == 'pull_request' && (needs.changes.outputs.macos_pr_side_runner" + " || needs.changes.outputs.macos_pr_runner || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15')", # Attempt 2 of a refused owned job: the owned pool once more. "github.event_name == 'pull_request' && github.run_attempt == 2 && github.triggering_actor == 'github-actions[bot]' && contains(needs.changes.outputs.macos_pr_owned_jobs," - " ' claude-wrapper ') && needs.changes.outputs.macos_pr_refused_retry_runner", + " ' claude-wrapper ') && (needs.changes.outputs.macos_pr_side_runner" + " || needs.changes.outputs.macos_pr_refused_retry_runner)", # A re-run of failed jobs on an owned-pool run, or a job the picker did not # place on the owned pool: the Blacksmith pool the picker named for it. "github.event_name == 'pull_request' && (github.run_attempt > 1 || !contains(needs.changes.outputs.macos_pr_owned_jobs," @@ -1459,19 +1466,25 @@ for file in sorted(Path(sys.argv[1]).glob("*.y*ml")): file.name == "ci.yml" and path == ("jobs", "changes", "outputs", "macos_pr_admission_runner") and value == "${{ " + ADMISSION_PICKED + " }}"): violations.append(f"{where}: reads the picker's admission runner outside macos_pr_admission_runner") + if SIDE_PICKED in value and not ( + file.name == "ci.yml" and path == ("jobs", "changes", "outputs", "macos_pr_side_runner") + and value == "${{ " + SIDE_PICKED + " }}"): + violations.append(f"{where}: reads the picker's side runner outside macos_pr_side_runner") if len(path) >= 3 and path[-2] == "with" and path[-1] in INPUTS: if value != INPUTS[path[-1]] or file.name != "ci.yml": violations.append(f"{where}: {path[-1]} must be exactly {INPUTS[path[-1]]}") continue if OUTPUT not in value and RETRY_OUTPUT not in value and REFUSED_OUTPUT not in value \ - and SHARD_OUTPUT not in value and ROOT_OUTPUT not in value and ADMISSION_OUTPUT not in value: + and SHARD_OUTPUT not in value and ROOT_OUTPUT not in value and ADMISSION_OUTPUT not in value \ + and SIDE_OUTPUT not in value: continue if path[-1:] == ("runs-on",): rest = value for branch in GUARDED: rest = rest.replace(branch, "") if OUTPUT not in rest and RETRY_OUTPUT not in rest and REFUSED_OUTPUT not in rest \ - and SHARD_OUTPUT not in rest and ROOT_OUTPUT not in rest and ADMISSION_OUTPUT not in rest: + and SHARD_OUTPUT not in rest and ROOT_OUTPUT not in rest and ADMISSION_OUTPUT not in rest \ + and SIDE_OUTPUT not in rest: continue violations.append(f"{where}: reads macos_pr_runner outside pr_runner or a pull_request runs-on branch") print("\n".join(violations))