ci: traffic-controller owns CI triggering (GITHUB_TOKEN updates, priority-ordered dispatch)
End the merge cascade and give the traffic-controller ownership of CI *triggering*. - autoupdate.yml updates PR branches with the built-in GITHUB_TOKEN instead of a PAT, so an update push no longer auto-retriggers CI (GitHub's anti-recursion rule) — the cascade (every merge re-runs every PR, cancel-in-progress thrashing them) is gone. - New scheduler ci-trigger.yml -> traffic_control.py --mode trigger (re-)triggers CI for the highest-priority PR(s) whose head SHA has absent/stale checks, a few at a time (inflight cap), in the existing P0-P9 / broken-draft priority order — a poor-man's merge queue reusing the priority core. It runs after autoupdate finishes (workflow_run, race-free) plus a cron backstop plus manual dispatch. - Triggering uses workflow_dispatch, which is EXEMPT from anti-recursion, so the built-in GITHUB_TOKEN (actions: write) starts the run — NO PAT / secret change needed. - ci.yml gains a workflow_dispatch trigger (pr/head_sha/reason inputs) and a per-PR concurrency group unifying pull_request and dispatch runs; its on: pull_request path is kept so brand-new PRs, human pushes, and fork PRs always get CI (fail-open). Pure select_triggers / classify_sha_runs decision core added to traffic_control.py with 24 new unit tests (priority order, oldest-first fairness, inflight cap, fork skip, P0 bypass+preempt, head-SHA needy classification, and a liveness/anti-starvation simulation). Closes #349 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,13 @@ preemption (P0 reclaims all strictly-lower; ANY higher PR reclaims a broken/draf
|
||||
lower run; P1-P9 never bump a *normal* lower run; self / main / equal-or-higher
|
||||
never cancelled), PASS 2 hold-back (yield to strictly-higher with an active run;
|
||||
same-level running-first then oldest-first), and a few end-to-end decision
|
||||
scenarios."""
|
||||
scenarios.
|
||||
|
||||
Also covers the --mode trigger scheduler core (issue #349): head-SHA run
|
||||
classification (absent/cancelled => needy; success/failure => not needy),
|
||||
select_triggers (priority order, oldest-first fairness, inflight cap, fork skip,
|
||||
P0 bypasses-cap-and-preempts), and a liveness/anti-starvation simulation proving
|
||||
every eligible PR is triggered within a bounded number of passes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -283,5 +289,221 @@ class SnapshotParsingTests(unittest.TestCase):
|
||||
self.assertEqual(self_run_id, 222)
|
||||
|
||||
|
||||
# ── --mode trigger scheduler core (issue #349) ───────────────────────────────
|
||||
def npr(number, labels=("P5",), *, draft=False, created_at="", status=tc.NONE, run_ids=()):
|
||||
"""Terse builder defaulting to a P5 PR (for the trigger tests)."""
|
||||
return pr(number, labels, draft=draft, created_at=created_at, status=status,
|
||||
run_ids=run_ids)
|
||||
|
||||
|
||||
class ClassifyShaRunsTests(unittest.TestCase):
|
||||
def test_no_runs_is_needy(self):
|
||||
status, ids, needy = tc.classify_sha_runs([])
|
||||
self.assertEqual(status, tc.NONE)
|
||||
self.assertEqual(ids, ())
|
||||
self.assertTrue(needy) # absent checks => must be triggered
|
||||
|
||||
def test_success_verdict_not_needy(self):
|
||||
_, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "completed", "conclusion": "success", "databaseId": 1}])
|
||||
self.assertEqual(ids, ())
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_failure_verdict_not_needy(self):
|
||||
# A real failure is the author's to fix — never auto-retriggered (no fail loop).
|
||||
_, _, needy = tc.classify_sha_runs(
|
||||
[{"status": "completed", "conclusion": "failure", "databaseId": 1}])
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_only_cancelled_is_needy(self):
|
||||
# Cancelled leaves no verdict → re-trigger so the PR can reach a mergeable state.
|
||||
_, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "completed", "conclusion": "cancelled", "databaseId": 1}])
|
||||
self.assertEqual(ids, ())
|
||||
self.assertTrue(needy)
|
||||
|
||||
def test_in_progress_is_active_not_needy(self):
|
||||
status, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "in_progress", "conclusion": None, "databaseId": 9}])
|
||||
self.assertEqual(status, tc.RUNNING)
|
||||
self.assertEqual(ids, (9,))
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_queued_is_active_not_needy(self):
|
||||
status, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "queued", "conclusion": None, "databaseId": 8}])
|
||||
self.assertEqual(status, tc.QUEUED)
|
||||
self.assertEqual(ids, (8,))
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_running_beats_queued_in_status(self):
|
||||
status, ids, _ = tc.classify_sha_runs([
|
||||
{"status": "queued", "databaseId": 1},
|
||||
{"status": "in_progress", "databaseId": 2},
|
||||
])
|
||||
self.assertEqual(status, tc.RUNNING)
|
||||
self.assertEqual(sorted(ids), [1, 2])
|
||||
|
||||
def test_cancelled_plus_active_not_needy(self):
|
||||
# An active run already covers the SHA — cancelled siblings don't make it needy.
|
||||
_, ids, needy = tc.classify_sha_runs([
|
||||
{"status": "completed", "conclusion": "cancelled", "databaseId": 1},
|
||||
{"status": "in_progress", "databaseId": 2},
|
||||
])
|
||||
self.assertEqual(ids, (2,))
|
||||
self.assertFalse(needy)
|
||||
|
||||
|
||||
class SelectTriggersTests(unittest.TestCase):
|
||||
def test_empty_needy_triggers_nothing(self):
|
||||
dec = tc.select_triggers([npr(1), npr(2)], set(), max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, ())
|
||||
|
||||
def test_single_needy_triggered(self):
|
||||
dec = tc.select_triggers([npr(1)], {1}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, (1,))
|
||||
self.assertEqual(dec.cancel_run_ids, ())
|
||||
|
||||
def test_priority_order(self):
|
||||
prs = [npr(1, ["P5"]), npr(2, ["P2"]), npr(3, ["P8"])]
|
||||
dec = tc.select_triggers(prs, {1, 2, 3}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, (2, 1, 3)) # P2, P5, P8
|
||||
|
||||
def test_same_level_oldest_first(self):
|
||||
prs = [
|
||||
npr(1, ["P5"], created_at="2026-07-03T00:00:00Z"),
|
||||
npr(2, ["P5"], created_at="2026-07-01T00:00:00Z"),
|
||||
npr(3, ["P5"], created_at="2026-07-02T00:00:00Z"),
|
||||
]
|
||||
dec = tc.select_triggers(prs, {1, 2, 3}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, (2, 3, 1)) # oldest createdAt first
|
||||
|
||||
def test_cap_limits_triggers(self):
|
||||
prs = [npr(1, ["P2"]), npr(2, ["P3"]), npr(3, ["P4"])]
|
||||
dec = tc.select_triggers(prs, {1, 2, 3}, max_inflight=2)
|
||||
self.assertEqual(dec.trigger_numbers, (1, 2)) # only 2 free slots
|
||||
self.assertEqual(dec.slots, 2)
|
||||
|
||||
def test_inflight_consumes_slots(self):
|
||||
prs = [
|
||||
npr(1, ["P5"], status=tc.RUNNING, run_ids=[100]), # inflight — occupies a slot
|
||||
npr(2, ["P2"]),
|
||||
npr(3, ["P3"]),
|
||||
]
|
||||
dec = tc.select_triggers(prs, {2, 3}, max_inflight=2)
|
||||
self.assertEqual(dec.slots, 1) # 2 cap - 1 inflight
|
||||
self.assertEqual(dec.trigger_numbers, (2,)) # highest-priority needy only
|
||||
self.assertEqual(dec.inflight_numbers, (1,))
|
||||
|
||||
def test_running_needy_is_not_retriggered(self):
|
||||
# Defensive: a PR flagged needy but already running is never a candidate.
|
||||
prs = [npr(1, ["P5"], status=tc.RUNNING, run_ids=[100])]
|
||||
dec = tc.select_triggers(prs, {1}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, ())
|
||||
|
||||
def test_fork_pr_skipped(self):
|
||||
dec = tc.select_triggers([npr(1, ["P2"]), npr(2, ["P1"])],
|
||||
{1, 2}, max_inflight=3, forks={2})
|
||||
self.assertEqual(dec.trigger_numbers, (1,)) # fork #2 not token-triggerable
|
||||
self.assertEqual(dec.skipped_fork_numbers, (2,))
|
||||
|
||||
def test_p0_bypasses_cap_and_preempts_lower(self):
|
||||
# Cap full (a P5 running), but a needy P0 still triggers AND preempts the strictly-
|
||||
# lower running run to free a runner immediately.
|
||||
prs = [
|
||||
npr(1, ["P5"], status=tc.RUNNING, run_ids=[500]), # inflight, strictly-lower
|
||||
npr(2, ["P0"]), # needy emergency
|
||||
]
|
||||
dec = tc.select_triggers(prs, {2}, max_inflight=1)
|
||||
self.assertEqual(dec.slots, 0) # cap is full
|
||||
self.assertEqual(dec.trigger_numbers, (2,)) # P0 bypasses the cap
|
||||
self.assertEqual(dec.cancel_run_ids, (500,)) # preempts the lower run
|
||||
|
||||
def test_p0_does_not_preempt_equal_priority(self):
|
||||
prs = [
|
||||
npr(1, ["P0"], status=tc.RUNNING, run_ids=[500]), # equal P0 — spared
|
||||
npr(2, ["P0"]), # needy emergency
|
||||
]
|
||||
dec = tc.select_triggers(prs, {2}, max_inflight=1)
|
||||
self.assertEqual(dec.trigger_numbers, (2,))
|
||||
self.assertEqual(dec.cancel_run_ids, ()) # never preempts an equal P0
|
||||
|
||||
def test_needy_numbers_reports_all_candidates_in_order(self):
|
||||
dec = tc.select_triggers([npr(1, ["P5"]), npr(2, ["P2"])], {1, 2}, max_inflight=1)
|
||||
self.assertEqual(dec.needy_numbers, (2, 1)) # priority order, cap-independent
|
||||
self.assertEqual(dec.trigger_numbers, (2,)) # but only 1 slot triggered
|
||||
|
||||
def test_zero_cap_is_clamped_to_one(self):
|
||||
# A misconfigured cap must never stall everything: clamp to >= 1 so at least the
|
||||
# top-priority needy PR still gets a slot (fail-safe forward progress).
|
||||
dec = tc.select_triggers([npr(1, ["P5"])], {1}, max_inflight=0)
|
||||
self.assertEqual(dec.max_inflight, 1)
|
||||
self.assertEqual(dec.trigger_numbers, (1,))
|
||||
|
||||
|
||||
class TriggerStarvationTests(unittest.TestCase):
|
||||
def test_every_needy_pr_is_triggered_within_bounded_passes(self):
|
||||
# Liveness / anti-starvation: with a fixed needy set and cap=2, simulate scheduler
|
||||
# passes where a triggered PR gains a run (leaves the needy set) and its run finishes
|
||||
# one pass later (freeing its slot). Mixed priorities prove lower-priority PRs are
|
||||
# served too — never starved — while higher-priority PRs still go first.
|
||||
labels = {1: ["P1"], 2: ["P1"], 3: ["P5"], 4: ["P5"],
|
||||
5: ["P8"], 6: ["P8"], 7: ["P5"]}
|
||||
created = {n: f"2026-07-{n:02d}T00:00:00Z" for n in labels}
|
||||
needy = set(labels)
|
||||
running: dict[int, int] = {} # number -> passes left running
|
||||
triggered_ever: set[int] = set()
|
||||
first_pass: dict[int, int] = {}
|
||||
cap, max_passes = 2, 12
|
||||
for pass_no in range(1, max_passes + 1):
|
||||
snap = [
|
||||
pr(n, labels[n], created_at=created[n],
|
||||
status=tc.RUNNING if n in running else tc.NONE,
|
||||
run_ids=[1000 + n] if n in running else [])
|
||||
for n in labels
|
||||
]
|
||||
dec = tc.select_triggers(snap, needy, max_inflight=cap)
|
||||
for n in dec.trigger_numbers:
|
||||
triggered_ever.add(n)
|
||||
first_pass.setdefault(n, pass_no)
|
||||
needy.discard(n) # gained a run -> no longer needy
|
||||
running[n] = 1 # occupies a slot for one pass
|
||||
for n in list(running): # running PRs finish after one pass
|
||||
running[n] -= 1
|
||||
if running[n] <= 0:
|
||||
del running[n]
|
||||
if not needy and not running:
|
||||
break
|
||||
self.assertEqual(triggered_ever, set(labels),
|
||||
"a PR was starved (never triggered)")
|
||||
# Priority respected: the two P1s go in the very first pass; everything else later.
|
||||
self.assertTrue(all(first_pass[n] == 1 for n in (1, 2)))
|
||||
self.assertTrue(all(first_pass[n] >= 2 for n in (3, 4, 5, 6, 7)))
|
||||
|
||||
|
||||
class TriggerSnapshotParsingTests(unittest.TestCase):
|
||||
def test_load_trigger_snapshot_roundtrip(self):
|
||||
text = json.dumps({
|
||||
"max_inflight": 2,
|
||||
"needy": [1, 3],
|
||||
"forks": [3],
|
||||
"prs": [
|
||||
{"number": 1, "labels": ["P2"], "createdAt": "2026-07-01T00:00:00Z"},
|
||||
{"number": 2, "labels": ["P5"], "runStatus": "running", "runIds": [22]},
|
||||
{"number": 3, "labels": ["P1"], "createdAt": "2026-07-02T00:00:00Z"},
|
||||
],
|
||||
})
|
||||
all_prs, needy, forks, cap = tc._load_trigger_snapshot(text)
|
||||
self.assertEqual(len(all_prs), 3)
|
||||
self.assertEqual(needy, {1, 3})
|
||||
self.assertEqual(forks, {3})
|
||||
self.assertEqual(cap, 2)
|
||||
dec = tc.select_triggers(all_prs, needy, max_inflight=cap, forks=forks)
|
||||
# #2 is inflight (uses a slot); #3 is a fork (skipped); only #1 fits the 1 free slot.
|
||||
self.assertEqual(dec.inflight_numbers, (2,))
|
||||
self.assertEqual(dec.skipped_fork_numbers, (3,))
|
||||
self.assertEqual(dec.trigger_numbers, (1,))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user