Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions scripts/ci/merge_compile_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
# conclusion but success is red.
NO_VERDICT = NOT_RUN | {"cancelled"}
TIMEOUT = 15 # seconds per gh call
try: # tests lower it; it is never raised
TIMEOUT = min(TIMEOUT, max(1, int(os.environ.get("CMUX_MERGE_GUARD_GH_TIMEOUT") or TIMEOUT)))
except ValueError:
pass
BUDGET = 100 # seconds for every call together, under the hook's 120 s: a hook that times out lets the command run
STARTED = time.monotonic()
FLEET_WORKFLOW = "fleet-compile-check.yml"
Expand Down Expand Up @@ -93,15 +97,33 @@ def changes_swift(path: str) -> bool:
return path.endswith(SWIFT_SUFFIXES) or path.rsplit("/", 1)[-1] in SWIFT_NAMES or ".xcodeproj/" in path


# Why the last read failed, for the refusal: a timeout, gh's own error, or the budget spent.
FAILED: list[str] = []


def run(args: list[str], cwd: str | None = None) -> str | None:
# The guard reads once per merge and never polls, so its gh reads skip the PATH guard's 30 s repeat throttle
# (~/.local/lib/cmux-local-build-guards/gh): the hook shares the session's CLAUDE_SESSION_ID, and after a lane's
# own `gh pr view N` that throttle held the hook's identical read past its timeout (#23611, 2026-10-21).
env = {**os.environ, "CMUX_GH_GUARD_DISABLE": "1"} if os.path.basename(args[0]) == "gh" else None
FAILED.clear()
left = BUDGET - (time.monotonic() - STARTED)
if left < 1:
FAILED[:] = [f"the guard's {BUDGET} s for GitHub reads was spent"]
return None # out of time: an unread answer refuses, or counts as no fleet pass
timeout = min(TIMEOUT, left)
try:
left = BUDGET - (time.monotonic() - STARTED)
if left < 1:
return None # out of time: an unread answer refuses, or counts as no fleet pass
done = subprocess.run(args, capture_output=True, text=True, timeout=min(TIMEOUT, left), cwd=cwd)
except (OSError, subprocess.SubprocessError):
done = subprocess.run(args, capture_output=True, text=True, timeout=timeout, cwd=cwd, env=env)
except subprocess.TimeoutExpired:
FAILED[:] = [f"`{' '.join(args[:3])[:60]}` did not answer within {timeout:.0f} s"]
return None
except (OSError, subprocess.SubprocessError) as error:
FAILED[:] = [f"`{args[0]}` could not run ({error})"]
return None
return done.stdout.strip() if done.returncode == 0 else None
if done.returncode != 0:
FAILED[:] = [f"`{' '.join(args[:3])[:60]}` failed: {(done.stderr or done.stdout).strip()[:160]}"]
return None
return done.stdout.strip()


def checkout_repo(cwd: str | None) -> str | None:
Expand Down Expand Up @@ -306,7 +328,12 @@ def verdict(pr: dict, files: list[str], checks: list[dict], fleet_passed: bool =
return why
if fleet_passed:
return ""
return why + f" Or compile-check the head on the build fleet: cmux-ci compile-check --ref {pr['head']} --app"
# No compile result on the head yet: the default path is the fleet's dev-build workers (COMMON.md), not
# waiting on the Mac Release compile.
what = changes_what(pr, swift)
return (f"{what} and {pr['head'][:12]} has no compile result yet. Compile-check it on the fleet dev-build "
f"workers: `cmux-ci compile-check --ref {pr['head']} --app`, then merge again; the guard reads that job. "
f"({why.removeprefix(what + ' and ')})")


def compile_wait(checks: list[dict]) -> bool:
Expand All @@ -321,10 +348,14 @@ def compile_wait(checks: list[dict]) -> bool:
or (len(finished) == len(named) and set(finished) - NOT_RUN == {"cancelled"}))


def changes_what(pr: dict, swift: list[str]) -> str:
return f"#{pr['number']} changes Swift ({swift[0]}{' and more' if len(swift) > 1 else ''})"


def compile_refusal(pr: dict, swift: list[str], checks: list[dict]) -> str:
named = [c for c in checks if c.get("name") == COMPILE]
head = pr["head"][:12]
what = f"#{pr['number']} changes Swift ({swift[0]}{' and more' if len(swift) > 1 else ''})"
what = changes_what(pr, swift)
if not checks:
return f"{what} and {head} has no checks yet; merge once its {COMPILE} passes."
if any(c.get("status") in RUNNING for c in named):
Expand Down Expand Up @@ -489,8 +520,10 @@ def refusal(command: str, cwd: str | None, reader=read_pr) -> str:
try:
why = reader(repo, number, where)
except Unreadable:
return (f"merge-compile-guard: could not read #{number} from GitHub (a REST rate limit or 403 is the usual "
f"cause), so it can't tell whether its Swift compile passed. Merge once GitHub answers.")
cause = FAILED[0] if FAILED else "GitHub gave no readable answer"
return (f"merge-compile-guard: could not read #{number} from GitHub ({cause}), so it can't tell whether "
f"its Swift compile passed. Retry the merge; if GitHub stays slow, check `glaeda-gh budget` and the "
f"host's load.")
if why:
return f"merge-compile-guard: {why}"
return ""
Expand Down
64 changes: 58 additions & 6 deletions tests/test_merge_compile_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys
import tempfile
import textwrap
import time
import unittest
from pathlib import Path

Expand Down Expand Up @@ -123,6 +124,32 @@ def test_a_refusal_says_how_to_get_a_fleet_pass(self):
self.assertIn(f"cmux-ci compile-check --ref {HEAD} --app", why)
self.assertNotIn("gh workflow run", why)

def test_a_missing_compile_result_names_the_fleet_compile_check_first(self):
"""COMMON.md: the default path is a fleet compile-check on the dev-build workers, not waiting on the Mac
Release compile."""
for checks in ([], [check(COMPILE, "queued")], [check("cmux-next path routing", "in_progress")]):
why = guard.verdict(pr(), SWIFT, checks)
run = f"cmux-ci compile-check --ref {HEAD} --app"
self.assertIn(run, why)
self.assertIn("fleet dev-build workers", why)
self.assertLess(why.index(run), why.index(COMPILE), why)

def test_the_fleet_compile_check_refusal_reads_whole_with_several_or_oddly_named_swift_files(self):
"""The refusal is built from its parts: a second file (" and more") or a path holding " and " can't split it."""
base = "Packages/macOS/CmuxNext/Sources/"
for swift, what in (([base + "A.swift", base + "B.swift"], f"({base}A.swift and more)"),
([base + "Find and Replace.swift"], f"({base}Find and Replace.swift)")):
why = guard.verdict(pr(), swift, [])
self.assertTrue(why.startswith(f"#7 changes Swift {what} and {HEAD[:12]} has no compile result yet."), why)
self.assertTrue(why.endswith(f"({HEAD[:12]} has no checks yet; merge once its {COMPILE} passes.)"), why)
self.assertEqual(why.count("cmux-ci compile-check"), 1, why)

def test_a_refusal_names_the_cause_of_its_own_read_not_an_earlier_one(self):
guard.run(["false"])
self.assertTrue(guard.FAILED)
self.assertEqual(guard.run(["true"]), "")
self.assertEqual(guard.FAILED, [])

def test_the_workflow_posts_the_status_the_guard_reads_on_the_sha_it_was_given(self):
text = (ROOT / ".github/workflows" / guard.FLEET_WORKFLOW).read_text()
self.assertIn(f"run-name: {guard.FLEET_TITLE}" + "${{ inputs.sha }}", text)
Expand Down Expand Up @@ -421,12 +448,17 @@ def test_a_merge_it_cannot_name_is_refused(self):
self.assertIn("can't name", why)


def fake_gh(bin_dir: Path, answers: dict[str, str], log: Path) -> None:
"""A `gh` that prints answers[<the first argument that names an endpoint>] and logs its argv."""
def fake_gh(bin_dir: Path, answers: dict[str, str], log: Path, throttle: float = 0, sleep: float = 0) -> None:
"""A `gh` that prints answers[<the first argument that names an endpoint>] and logs its argv. throttle: seconds
it sleeps first unless CMUX_GH_GUARD_DISABLE=1, as the PATH guard ~/.local/lib/cmux-local-build-guards/gh does
for a read its caller repeats within 30 s; sleep: seconds it sleeps whatever the environment (a slow GitHub)."""
script = bin_dir / "gh"
script.write_text(textwrap.dedent(f"""\
#!{sys.executable}
import json, sys
import json, os, sys, time
if os.environ.get("CMUX_GH_GUARD_DISABLE") != "1":
time.sleep({throttle})
time.sleep({sleep})
args = sys.argv[1:]
with open({str(log)!r}, "a") as f:
f.write(json.dumps(args) + "\\n")
Expand All @@ -443,7 +475,8 @@ def fake_gh(bin_dir: Path, answers: dict[str, str], log: Path) -> None:

class Hook(unittest.TestCase):
def run_hook(self, command: str, compile_state: tuple[str, str | None] | None, statuses=None, fleet_runs=None,
other_statuses=None, on_base="ahead", head=HEAD, cmux_ci_jobs=(), cmux_ci_receipts=None):
other_statuses=None, on_base="ahead", head=HEAD, cmux_ci_jobs=(), cmux_ci_receipts=None,
gh_throttle=0, gh_sleep=0, env_extra=None):
"""statuses: the head's fleet/compile-check statuses; fleet_runs: {run id: fleet_run()}; other_statuses:
statuses on another sha; cmux_ci_jobs, cmux_ci_receipts: what the controller holds (fake_cmux_ci)."""
with tempfile.TemporaryDirectory() as tmp:
Expand Down Expand Up @@ -473,11 +506,13 @@ def run_hook(self, command: str, compile_state: tuple[str, str | None] | None, s
answers["repos/manaflow-ai/cmux/branches/feat-cmux-next"] = json.dumps(TIP)
if on_base:
answers[f"repos/manaflow-ai/cmux/compare/{RUN_SHA}...{TIP}"] = json.dumps(on_base)
fake_gh(bin_dir, answers, log)
fake_gh(bin_dir, answers, log, throttle=gh_throttle, sleep=gh_sleep)
client = fake_cmux_ci(bin_dir, list(cmux_ci_jobs) if cmux_ci_jobs is not None else None,
cmux_ci_receipts or {JOB_23606["id"]: RECEIPTS_23606}, log)
payload = json.dumps({"tool_name": "Bash", "cwd": str(tmp), "tool_input": {"command": command}})
env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "CMUX_CI_CLIENT": str(client)}
env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "CMUX_CI_CLIENT": str(client),
**(env_extra or {})}
env.pop("CMUX_GH_GUARD_DISABLE", None)
done = subprocess.run(["bash", str(HOOK)], input=payload, capture_output=True, text=True, env=env,
timeout=60)
self.assertEqual(done.returncode, 0, done.stderr)
Expand All @@ -504,6 +539,23 @@ def test_a_bare_merge_checks_the_checkouts_branch(self):
out, _ = self.run_hook("gh pr merge --squash", ("completed", "success"))
self.assertIsNone(out)

def test_the_gh_guards_repeat_throttle_does_not_slow_the_guard(self):
"""2026-10-21: the hook shares the session's CLAUDE_SESSION_ID, so after a lane's `gh pr view N` the PATH guard
held the hook's identical read up to 30 s and #23611's merges were refused as unreadable. The guard reads once
per merge and never polls: its reads skip the throttle."""
started = time.monotonic()
out, _ = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("completed", "success"), gh_throttle=30)
self.assertIsNone(out)
self.assertLess(time.monotonic() - started, 10)

def test_a_slow_github_refuses_and_says_it_timed_out(self):
out, _ = self.run_hook("gh pr merge 22835 -R manaflow-ai/cmux --squash", ("completed", "success"), gh_sleep=5,
env_extra={"CMUX_MERGE_GUARD_GH_TIMEOUT": "1"})
why = out["hookSpecificOutput"]["permissionDecisionReason"]
self.assertIn("could not read #22835 from GitHub", why)
self.assertIn("did not answer within 1 s", why)
self.assertNotIn("rate limit", why)

def test_an_unreadable_github_refuses(self):
out, _ = self.run_hook("gh pr merge 1 -R manaflow-ai/cmux --squash", None)
self.assertIn("could not read #1 from GitHub", out["hookSpecificOutput"]["permissionDecisionReason"])
Expand Down