diff --git a/services/hackbot-pulse-listener/app/__main__.py b/services/hackbot-pulse-listener/app/__main__.py index 80c99d76e7..76d736f138 100644 --- a/services/hackbot-pulse-listener/app/__main__.py +++ b/services/hackbot-pulse-listener/app/__main__.py @@ -19,7 +19,7 @@ def main() -> None: logger.warning("PULSE_USER/PULSE_PASSWORD not set; listener will not start") return - executor = ThreadPoolExecutor(max_workers=settings.poll_max_workers) + executor = ThreadPoolExecutor(max_workers=settings.max_workers) consumer_obj = consumer.build_consumer(executor) def shutdown(signum, _frame): diff --git a/services/hackbot-pulse-listener/app/config.py b/services/hackbot-pulse-listener/app/config.py index 969cff3e8c..2525501525 100644 --- a/services/hackbot-pulse-listener/app/config.py +++ b/services/hackbot-pulse-listener/app/config.py @@ -32,7 +32,12 @@ class Settings(BaseSettings): # Polling the API for run completion poll_interval_seconds: int = 60 run_max_age_minutes: int = 12 * 60 - poll_max_workers: int = 8 + # Shared worker pool for message processing and run polling. A + # regression check may block for a few minutes waiting for a parent + # build to settle, so the pool is sized well above the number of + # builds/runs in flight at once. Threads are cheap and mostly idle + # while waiting. + max_workers: int = 256 # Email notifications (SendGrid) sendgrid_api_key: str | None = None diff --git a/services/hackbot-pulse-listener/app/consumer.py b/services/hackbot-pulse-listener/app/consumer.py index 427dc403bf..7a53471438 100644 --- a/services/hackbot-pulse-listener/app/consumer.py +++ b/services/hackbot-pulse-listener/app/consumer.py @@ -1,11 +1,12 @@ import logging +import threading from concurrent.futures import Executor from cachetools import TTLCache from kombu import Connection, Exchange, Queue from kombu.mixins import ConsumerMixin -from app import client, lando, taskcluster, worker +from app import client, lando, regression, taskcluster, worker from app.config import settings from app.models import RunContext @@ -15,11 +16,16 @@ EXCHANGES = ("exchange/taskcluster-queue/v1/task-failed",) -# In-memory dedupe of hg revisions already handed to the agent. Only the -# single consumer thread touches it, so no lock is needed. +# In-memory dedupe of hg revisions already handed to the agent. A revision is +# recorded only once we actually trigger a run, so an inherited failure on one +# build label never suppresses a genuine regression on another label of the +# same push, while a revision that breaks many builds still triggers only once. +# Messages are handled on worker threads, so the check-and-record is done under +# a lock. _seen: TTLCache = TTLCache( maxsize=settings.dedupe_max_size, ttl=settings.dedupe_ttl_seconds ) +_seen_lock = threading.Lock() def process(body: dict, executor: Executor) -> str | None: @@ -43,10 +49,25 @@ def process(body: dict, executor: Executor) -> str | None: logger.warning("No GECKO_HEAD_REV for task %s; skipping", task_id) return None - if hg_revision in _seen: - logger.info("Revision %s already processed; skipping", hg_revision) + with _seen_lock: + already_seen = hg_revision in _seen + if already_seen: + logger.info("Revision %s already triggered a run; skipping", hg_revision) return None - _seen[hg_revision] = True + + if not regression.is_new_build_failure(project, hg_revision, task_label): + logger.info( + "Build %s at %s inherited from an ancestor push; skipping", + task_label, + hg_revision, + ) + return None + + with _seen_lock: + if hg_revision in _seen: + logger.info("Revision %s already triggered a run; skipping", hg_revision) + return None + _seen[hg_revision] = True git_commit = lando.hg_to_git(hg_revision) if not git_commit: @@ -56,7 +77,8 @@ def process(body: dict, executor: Executor) -> str | None: task_id, project, ) - _seen.pop(hg_revision, None) + with _seen_lock: + _seen.pop(hg_revision, None) return None inputs: dict = { @@ -73,7 +95,8 @@ def process(body: dict, executor: Executor) -> str | None: run_id = client.trigger_run(inputs) except Exception: logger.exception("Failed to trigger build-repair run for %s", hg_revision) - _seen.pop(hg_revision, None) + with _seen_lock: + _seen.pop(hg_revision, None) return None logger.info( @@ -97,11 +120,19 @@ def process(body: dict, executor: Executor) -> str | None: def make_handler(executor: Executor): - def on_message(body, message): + def run(body: dict) -> None: try: process(body, executor) except Exception: logger.exception("Error handling pulse message") + + def on_message(body, message): + # Process on a worker thread so a regression check that blocks waiting + # for a parent build to settle never stalls the consumer thread. + try: + executor.submit(run, body) + except Exception: + logger.exception("Failed to dispatch pulse message") finally: message.ack() @@ -109,12 +140,17 @@ def on_message(body, message): def _build_queues(user: str) -> list[Queue]: + # Both local and prod authenticate as the same pulse user, so the queue name + # must also vary by environment; otherwise both consumers bind to the same + # durable queue and steal each other's messages. + env = settings.environment + env_segment = "" if env == "production" else f"{env}-" queues = [] for exchange in EXCHANGES: suffix = exchange.rsplit("/", 1)[-1] queues.append( Queue( - name=f"queue/{user}/build-repair-{suffix}", + name=f"queue/{user}/build-repair-{env_segment}{suffix}", exchange=Exchange(exchange, type="topic", no_declare=True), routing_key="#", durable=True, diff --git a/services/hackbot-pulse-listener/app/regression.py b/services/hackbot-pulse-listener/app/regression.py new file mode 100644 index 0000000000..6445a912e1 --- /dev/null +++ b/services/hackbot-pulse-listener/app/regression.py @@ -0,0 +1,166 @@ +import logging +import time + +from mozci.errors import ParentPushNotFound +from mozci.push import MAX_DEPTH, Push + +logger = logging.getLogger(__name__) + + +# When the nearest ancestor that ran the build has not produced a decisive +# result yet, wait for it in-process instead of racing ahead and misreporting an +# inherited failure as new. A build can take tens of minutes and may hit an +# infra exception and be auto-retried, so we poll until it settles or the +# deadline elapses (well above a normal build + one retry). Fails open after the +# deadline so a real regression is never silently dropped. +POLL_INTERVAL_SECONDS = 120 +MAX_WAIT_SECONDS = 60 * 60 + +# A green build; mozci reports it as "passed" (Taskcluster) or "success" +# (Treeherder). Failures are read from mozci's Task.failed attribute instead of +# reimplementing the vocabulary. +_PASSED_RESULTS = ("passed", "success") + +# A build in one of these states, or with one of these (infra) results, has not +# settled: it is still running or was retried after an exception, so its outcome +# is not knowable yet and we wait for it. Anything else that is not a decisive +# pass/fail (unscheduled, canceled, superseded, ...) or a build that never ran +# at all (coalesced) is treated as non-decisive and skipped. +_UNSETTLED_STATES = ("pending", "running", "exception") +_UNSETTLED_RESULTS = ("exception", "retry") + +# Sentinel meaning the decision cannot be made yet because an ancestor build has +# not settled; the caller waits and re-checks. +_PENDING = object() + + +def _build_status(push: Push, label: str): + """Return 'passed', 'failed', _PENDING, or None for a build label on a push. + + 'passed'/'failed' come from a run with a decisive result. _PENDING means the + result is not knowable yet: a task exists but has not settled (still running, + or exceptioned and awaiting a retry), or no task is visible yet but the build + was scheduled to run on this push (its result has not propagated). None means + the build produced no decisive result and none is coming: it was coalesced / + never scheduled, or it only reached a non-decisive terminal state. None is + deliberately non-decisive so coalescing gaps are skipped and never suppress a + real regression. Retriggers are collapsed: any green run means 'passed'; only + a genuine build failure counts as 'failed'. + """ + label_tasks = [t for t in push.tasks if t.label == label] + if label_tasks: + if any(t.result in _PASSED_RESULTS for t in label_tasks): + return "passed" + # Checked before failure: a retrigger that is still running or was + # exceptioned and auto-retried may yet turn green, and any green run wins, + # so we wait for it rather than prematurely inheriting a failure. + if any( + t.state in _UNSETTLED_STATES or t.result in _UNSETTLED_RESULTS + for t in label_tasks + ): + return _PENDING + # Not "not t.failed": a run can be neither passed nor failed (canceled, + # superseded, ...). mozci's Task.failed also counts `exception`, but those + # are unsettled and already returned above, so only genuine build failures + # reach here. + if any(t.failed for t in label_tasks): + return "failed" + # Ran but reached a non-decisive terminal state (canceled, ...): skip. + return None + + # No task for this label on the push. Distinguish a build that was scheduled + # to run here but whose result is not visible yet (wait) from one that was + # never scheduled / coalesced away (skip), using the decision task's + # scheduled set (available well before the builds themselves finish). + try: + scheduled = label in push.scheduled_task_labels + except Exception: + logger.debug("Could not read scheduled task labels for %s", push.rev) + scheduled = False + return _PENDING if scheduled else None + + +def _classify(branch: str, rev: str, label: str): + """Walk ancestors once. Returns True (new), False (inherited), or _PENDING. + + A fresh Push is built each call so re-checks re-fetch live data (recent + pushes are not finalized in mozci, so their tasks are never served from + cache). + """ + ancestor = Push(rev, branch=branch) + for _ in range(MAX_DEPTH): + try: + ancestor = ancestor.parent + except ParentPushNotFound: + break + status = _build_status(ancestor, label) + if status is None: + continue + if status is _PENDING: + logger.info( + "Build %s not settled yet at %s; deferring decision for %s", + label, + ancestor.rev, + rev, + ) + return _PENDING + if status == "failed": + logger.info( + "Build %s already failing at %s; inherited failure at %s", + label, + ancestor.rev, + rev, + ) + return False + logger.info( + "Build %s passed at %s; new failure introduced at %s", + label, + ancestor.rev, + rev, + ) + return True + + logger.warning( + "No ancestor within %s pushes ran build %s; running agent", MAX_DEPTH, label + ) + return True + + +def is_new_build_failure(branch: str, rev: str, label: str) -> bool: + """Return True if this push introduced the failure, False if it inherited it. + + Walks back over pushes that did not run the build (coalescing) until it + finds the nearest ancestor that did. When that ancestor's build has not + settled yet, waits in-process and re-checks until it produces a decisive + result or MAX_WAIT_SECONDS elapses, rather than racing ahead and + misreporting an inherited failure as new. Fails open (returns True) on any + mozci/network error, if the build stays unsettled past the deadline, or if + no ancestor within MAX_DEPTH ran the build, so we never silently drop a real + regression. + """ + try: + deadline = time.monotonic() + MAX_WAIT_SECONDS + while True: + result = _classify(branch, rev, label) + if result is not _PENDING: + return result + if time.monotonic() >= deadline: + break + logger.info( + "Waiting %ss for an unsettled ancestor build of %s (%s)", + POLL_INTERVAL_SECONDS, + rev, + label, + ) + time.sleep(POLL_INTERVAL_SECONDS) + except Exception: + logger.exception("Regression check failed for %s@%s; running agent", label, rev) + return True + + logger.warning( + "Build %s still unsettled after %ss at %s; running agent", + label, + MAX_WAIT_SECONDS, + rev, + ) + return True diff --git a/services/hackbot-pulse-listener/deploy.sh b/services/hackbot-pulse-listener/deploy.sh index 5f52c373b6..81ef49d95e 100755 --- a/services/hackbot-pulse-listener/deploy.sh +++ b/services/hackbot-pulse-listener/deploy.sh @@ -100,6 +100,7 @@ gcloud builds submit "${ROOT_DIR}" \ echo "==> Deploying worker pool" ENV_VARS="HACKBOT_API_URL=${HACKBOT_API_URL},HACKBOT_UI_URL=${HACKBOT_UI_URL}" +ENV_VARS="${ENV_VARS},ENVIRONMENT=production" ENV_VARS="${ENV_VARS},PULSE_USER=${PULSE_USER},WATCHED_REPOS=${WATCHED_REPOS}" ENV_VARS="${ENV_VARS},NOTIFICATION_SENDER=${NOTIFICATION_SENDER}" ENV_VARS="${ENV_VARS},NOTIFICATION_TEAM_EMAIL=${NOTIFICATION_TEAM_EMAIL}" diff --git a/services/hackbot-pulse-listener/pyproject.toml b/services/hackbot-pulse-listener/pyproject.toml index 821bad9511..a9b9dae4be 100644 --- a/services/hackbot-pulse-listener/pyproject.toml +++ b/services/hackbot-pulse-listener/pyproject.toml @@ -5,6 +5,7 @@ description = "Listens to Taskcluster build failures and triggers the build-repa requires-python = ">=3.12" dependencies = [ "kombu>=5.6,<6", + "mozci~=2.4.8", "taskcluster>=97.1,<100.6", "httpx>=0.26.0", "pydantic-settings>=2.1.0", @@ -12,6 +13,9 @@ dependencies = [ "markdown2>=2.4.0", "cachetools>=5.3.0", "sentry-sdk>=2.51.0", + # mozci imports zstandard eagerly (default cache serializer) but only + # declares it under its optional `cache` extra, so pull it in explicitly. + "zstandard~=0.25.0", ] [project.optional-dependencies] diff --git a/services/hackbot-pulse-listener/tests/test_consumer.py b/services/hackbot-pulse-listener/tests/test_consumer.py index 08e5981b7c..df9662b2a9 100644 --- a/services/hackbot-pulse-listener/tests/test_consumer.py +++ b/services/hackbot-pulse-listener/tests/test_consumer.py @@ -58,6 +58,7 @@ def test_build_failure_triggers_run_and_submits_poll(): with ( patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), patch.object(consumer.lando, "hg_to_git", return_value="deadbeef"), + patch.object(consumer.regression, "is_new_build_failure", return_value=True), patch.object(consumer.client, "trigger_run", return_value="run-1") as trigger, ): run_id = consumer.process(_build_msg(), executor) @@ -83,6 +84,7 @@ def test_same_revision_triggers_once(): with ( patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), patch.object(consumer.lando, "hg_to_git", return_value="deadbeef"), + patch.object(consumer.regression, "is_new_build_failure", return_value=True), patch.object(consumer.client, "trigger_run", return_value="run-1") as trigger, ): consumer.process(_build_msg(task_id="T1"), executor) @@ -91,6 +93,63 @@ def test_same_revision_triggers_once(): trigger.assert_called_once() +def test_inherited_failure_is_skipped_before_mapping(): + executor = MagicMock() + with ( + patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), + patch.object(consumer.regression, "is_new_build_failure", return_value=False), + patch.object(consumer.lando, "hg_to_git") as hg_to_git, + patch.object(consumer.client, "trigger_run") as trigger, + ): + assert consumer.process(_build_msg(), executor) is None + + hg_to_git.assert_not_called() + trigger.assert_not_called() + executor.submit.assert_not_called() + + +def test_multiple_builds_same_revision_trigger_once(): + executor = MagicMock() + with ( + patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), + patch.object(consumer.lando, "hg_to_git", return_value="deadbeef"), + patch.object(consumer.regression, "is_new_build_failure", return_value=True), + patch.object(consumer.client, "trigger_run", return_value="run-1") as trigger, + ): + consumer.process(_build_msg(task_id="T1", label="build-linux64/opt"), executor) + consumer.process(_build_msg(task_id="T2", label="build-macosx64/opt"), executor) + + trigger.assert_called_once() + + +def test_inherited_label_does_not_suppress_new_label_on_same_revision(): + executor = MagicMock() + with ( + patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), + patch.object(consumer.lando, "hg_to_git", return_value="deadbeef"), + patch.object( + consumer.regression, "is_new_build_failure", side_effect=[False, True] + ), + patch.object(consumer.client, "trigger_run", return_value="run-1") as trigger, + ): + # Inherited failure on the first label must not mark the revision seen. + assert ( + consumer.process( + _build_msg(task_id="T1", label="build-linux64/opt"), executor + ) + is None + ) + # A genuine regression on another label of the same push still runs. + assert ( + consumer.process( + _build_msg(task_id="T2", label="build-macosx64/opt"), executor + ) + == "run-1" + ) + + trigger.assert_called_once() + + def test_unwatched_project_skipped_before_api_call(): executor = MagicMock() with ( @@ -107,6 +166,7 @@ def test_unmappable_revision_skipped(): executor = MagicMock() with ( patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), + patch.object(consumer.regression, "is_new_build_failure", return_value=True), patch.object(consumer.lando, "hg_to_git", return_value=None), patch.object(consumer.client, "trigger_run") as trigger, ): @@ -121,6 +181,7 @@ def test_trigger_failure_releases_revision_for_retry(): with ( patch.object(consumer.taskcluster, "get_hg_revision", return_value="hgrev"), patch.object(consumer.lando, "hg_to_git", return_value="deadbeef"), + patch.object(consumer.regression, "is_new_build_failure", return_value=True), patch.object( consumer.client, "trigger_run", side_effect=[RuntimeError("boom"), "run-2"] ) as trigger, @@ -130,3 +191,15 @@ def test_trigger_failure_releases_revision_for_retry(): assert consumer.process(_build_msg(task_id="T2"), executor) == "run-2" assert trigger.call_count == 2 + + +def test_queue_name_includes_non_production_environment(): + with patch.object(consumer.settings, "environment", "development"): + (queue,) = consumer._build_queues("guest") + assert queue.name == "queue/guest/build-repair-development-task-failed" + + +def test_queue_name_omits_production_environment(): + with patch.object(consumer.settings, "environment", "production"): + (queue,) = consumer._build_queues("guest") + assert queue.name == "queue/guest/build-repair-task-failed" diff --git a/services/hackbot-pulse-listener/tests/test_regression.py b/services/hackbot-pulse-listener/tests/test_regression.py new file mode 100644 index 0000000000..520fcd37a9 --- /dev/null +++ b/services/hackbot-pulse-listener/tests/test_regression.py @@ -0,0 +1,163 @@ +from unittest.mock import patch + +from app import regression +from mozci.errors import ParentPushNotFound + +LABEL = "build-linux64/opt" + + +class FakeTask: + def __init__(self, label=LABEL, state="completed", result="passed"): + self.label = label + self.state = state + self.result = result + + @property + def failed(self): + return self.result in ("busted", "failed", "exception") + + +class FakePush: + def __init__(self, rev, tasks, parent=None): + self.rev = rev + self.tasks = tasks + self._parent = parent + + @property + def parent(self): + if self._parent is None: + raise ParentPushNotFound("no parent", rev=self.rev, branch="autoland") + return self._parent + + +def _passed(label=LABEL): + return [FakeTask(label=label, result="passed")] + + +def _failed(label=LABEL): + return [FakeTask(label=label, result="failed")] + + +def _infra(label=LABEL): + return [FakeTask(label=label, state="exception", result="exception")] + + +def _running(label=LABEL): + return [FakeTask(label=label, state="running", result=None)] + + +def _chain(*task_lists): + """Build a parent chain: task_lists[0] is the observed push, [1] its parent, ...""" + push = None + for i, tasks in enumerate(reversed(task_lists)): + push = FakePush(rev=f"rev{len(task_lists) - 1 - i}", tasks=tasks, parent=push) + return push + + +def _run(observed): + with ( + patch.object(regression, "Push", return_value=observed), + patch.object(regression.time, "sleep"), + ): + return regression.is_new_build_failure("autoland", observed.rev, LABEL) + + +def _run_polling(observed_sequence): + """Feed a fresh observed-push chain on each poll, simulating builds settling.""" + with ( + patch.object(regression, "Push", side_effect=observed_sequence), + patch.object(regression.time, "sleep"), + ): + return regression.is_new_build_failure( + "autoland", observed_sequence[0].rev, LABEL + ) + + +def test_parent_passed_is_new_failure(): + assert _run(_chain(_failed(), _passed())) is True + + +def test_parent_failed_is_inherited(): + assert _run(_chain(_failed(), _failed())) is False + + +def test_parent_intermittent_is_new_failure(): + parent_tasks = [ + FakeTask(result="failed"), + FakeTask(result="passed"), + ] + assert _run(_chain(_failed(), parent_tasks)) is True + + +def test_coalesced_parent_then_green_grandparent_is_new_failure(): + assert _run(_chain(_failed(), [], _passed())) is True + + +def test_coalesced_parent_then_failed_grandparent_is_inherited(): + assert _run(_chain(_failed(), [], _failed())) is False + + +def test_infra_parent_is_waited_then_new_failure(): + # An exceptioned parent build is polled, not skipped; once its retry lands + # green the observed push introduced the failure. + assert ( + _run_polling([_chain(_failed(), _infra()), _chain(_failed(), _passed())]) + is True + ) + + +def test_running_parent_is_waited_then_inherited(): + # A still-running parent is polled until it settles; a failure there means + # the observed push inherited it. + assert ( + _run_polling([_chain(_failed(), _running()), _chain(_failed(), _failed())]) + is False + ) + + +def test_unsettled_parent_past_deadline_runs_agent(): + # An ancestor build that never settles fails open once the deadline passes. + observed = _chain(_failed(), _running()) + with ( + patch.object(regression, "Push", return_value=observed), + patch.object(regression.time, "sleep"), + patch.object( + regression.time, + "monotonic", + side_effect=[0.0, regression.MAX_WAIT_SECONDS + 1], + ), + ): + assert regression.is_new_build_failure("autoland", observed.rev, LABEL) is True + + +def test_no_parent_runs_agent(): + assert _run(_chain(_failed())) is True + + +def test_no_decisive_ancestor_runs_agent(): + empties = [_failed()] + [[] for _ in range(regression.MAX_DEPTH + 2)] + assert _run(_chain(*empties)) is True + + +def test_mozci_error_runs_agent(): + with patch.object(regression, "Push", side_effect=RuntimeError("boom")): + assert regression.is_new_build_failure("autoland", "rev", LABEL) is True + + +def test_other_label_on_parent_ignored(): + parent_tasks = _failed(label="build-macosx64/opt") # different build, not ours + assert _run(_chain(_failed(), parent_tasks)) is True + + +def test_exception_parent_is_waited_not_failed(): + # A completed task with an infra `exception` result is unsettled, not a + # failure: we wait for the retry rather than declaring the parent failed. + exception_parent = [FakeTask(state="completed", result="exception")] + settled = _chain(_failed(), _passed()) + assert _run_polling([_chain(_failed(), exception_parent), settled]) is True + + +def test_treeherder_result_vocabulary(): + # success/busted are the Treeherder-source spellings of passed/failed. + assert _run(_chain(_failed(), [FakeTask(result="success")])) is True + assert _run(_chain(_failed(), [FakeTask(result="busted")])) is False diff --git a/uv.lock b/uv.lock index 43fd43cdd1..8c3466b240 100644 --- a/uv.lock +++ b/uv.lock @@ -2051,15 +2051,15 @@ grpc = [ [[package]] name = "google-auth" -version = "2.55.2" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/f6/494e18317546d7def90c957b71d68b025d24f0e22e486c2606bc57765c48/google_auth-2.54.0.tar.gz", hash = "sha256:130f6fd5e3f497fdad897a23ed9489973437edf561238c4b92a4d02c435f8af9", size = 343161, upload-time = "2026-06-12T18:03:17.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c6/02eb5a337ac316a4c30c012e747bad5cea36e1a876efecdf80865541f7d8/google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b", size = 256778, upload-time = "2026-07-07T18:43:19.52Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/d53bddd2c0949833fcb4ea06f9d5dd1c40575a1a4214cd1021eff57ba301/google_auth-2.54.0-py3-none-any.whl", hash = "sha256:784e9837f92244141250470d47c893df50cbab485ce491aca5e9deb558ad2b48", size = 249878, upload-time = "2026-06-12T18:02:57.58Z" }, ] [package.optional-dependencies] @@ -2666,10 +2666,12 @@ dependencies = [ { name = "httpx" }, { name = "kombu" }, { name = "markdown2" }, + { name = "mozci" }, { name = "pydantic-settings" }, { name = "sendgrid" }, { name = "sentry-sdk" }, { name = "taskcluster" }, + { name = "zstandard" }, ] [package.optional-dependencies] @@ -2683,11 +2685,13 @@ requires-dist = [ { name = "httpx", specifier = ">=0.26.0" }, { name = "kombu", specifier = ">=5.6,<6" }, { name = "markdown2", specifier = ">=2.4.0" }, + { name = "mozci", specifier = "~=2.4.8" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "sendgrid", specifier = ">=6.12.5" }, { name = "sentry-sdk", specifier = ">=2.51.0" }, { name = "taskcluster", specifier = ">=97.1,<100.6" }, + { name = "zstandard", specifier = "~=0.25.0" }, ] provides-extras = ["dev"] @@ -4096,7 +4100,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -4114,9 +4118,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, ] [[package]] @@ -7742,20 +7746,20 @@ wheels = [ [[package]] name = "xgboost" -version = "3.3.0" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/41/846d4de2b8fc694073fd3ac5052caf68caa1ea11cb7fa32d7ad9c049b232/xgboost-3.3.0.tar.gz", hash = "sha256:58bcb8a4cace648cdab7b94fa4f16d2c9ff26d90dd4d26907168106fa06d8746", size = 1224702, upload-time = "2026-06-17T21:26:50.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/bb/1eb0242409d22db725d7a88088e6cfd6556829fb0736f9ff69aa9f1e9455/xgboost-3.2.0.tar.gz", hash = "sha256:99b0e9a2a64896cdaf509c5e46372d336c692406646d20f2af505003c0c5d70d", size = 1263936, upload-time = "2026-02-10T11:03:05.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/72/3b68983c0215ef65d48e9eeb1f168c3c6e3d62a61ece605de3209c79cae1/xgboost-3.3.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:07688a377046b8640897b62421150bf73c6cc7101823474ec6ad08b93290f587", size = 2553505, upload-time = "2026-06-17T21:21:32.146Z" }, - { url = "https://files.pythonhosted.org/packages/c9/62/b49e756822b29909d0c95ed334662dc6c7c81a99ec6bc10dc18e69f3d6e7/xgboost-3.3.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:af7cea10f418b7c251ddc8da440f57bdab2990b5fc9f74a35a92b0f150ea287d", size = 2376040, upload-time = "2026-06-17T21:22:01.981Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/a0adcd1ee28f525bd5c9dc3ebe78a7599bf97c22866d6449f967b829e338/xgboost-3.3.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:624a83aeb1e7ba081719795db179f4ce6fff12e79de05cd9baf15ee48fd22f0e", size = 98180629, upload-time = "2026-06-17T21:24:00.804Z" }, - { url = "https://files.pythonhosted.org/packages/47/1f/8b3e578cfd8e3bcdb4374e2bbe0b40b4e5320accb5cbdcf535ecc512eb5c/xgboost-3.3.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f59edaf28eccd1c519788607c72ed907ee6cedfa933d706620bc1612d24b354e", size = 98716607, upload-time = "2026-06-17T21:26:21.058Z" }, - { url = "https://files.pythonhosted.org/packages/07/6b/087fd5d28fdbb90d385c50ee9308a820241b82feebdf42e72e19a48e4b32/xgboost-3.3.0-py3-none-win_amd64.whl", hash = "sha256:b06057f6a018fc04e6b3e0c15568ca636b8151a5b5f333478e500fcaf4fc7594", size = 69522696, upload-time = "2026-06-17T21:20:53.707Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/6e4cdd877c24adf56cb3586bc96d93d4dcd780b5ea1efb32e1ee0de08bae/xgboost-3.2.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:2f661966d3e322536d9c448090a870fcba1e32ee5760c10b7c46bac7a342079a", size = 2507014, upload-time = "2026-02-10T10:50:57.44Z" }, + { url = "https://files.pythonhosted.org/packages/93/f1/c09ef1add609453aa3ba5bafcd0d1c1a805c1263c0b60138ec968f8ec296/xgboost-3.2.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:eabbd40d474b8dbf6cb3536325f9150b9e6f0db32d18de9914fb3227d0bef5b7", size = 2328527, upload-time = "2026-02-10T10:51:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/d9914a7b8df842832850b1a18e5f47aaa071c217cdd1da2ae9deb291018b/xgboost-3.2.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:852eabc6d3b3702a59bf78dbfdcd1cb9c4d3a3b6e5ed1f8781d8b9512354fdd2", size = 131100954, upload-time = "2026-02-10T11:02:42.704Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/679de17c2caa4fd3b0b4386ecf7377301702cb0afb22930a07c142fcb1d8/xgboost-3.2.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:99b4a6bbcb47212fec5cf5fbe12347215f073c08967431b0122cfbd1ee70312c", size = 131748579, upload-time = "2026-02-10T10:54:40.424Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1661dd114a914a67e3f7ab66fa1382e7599c2a8c340f314ad30a3e2b4d08/xgboost-3.2.0-py3-none-win_amd64.whl", hash = "sha256:0d169736fd836fc13646c7ab787167b3a8110351c2c6bc770c755ee1618f0442", size = 101681668, upload-time = "2026-02-10T10:59:31.202Z" }, ] [[package]]