diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py index 6466c90218..46332cfc2f 100755 --- a/scripts/ci/agent_mention_router.py +++ b/scripts/ci/agent_mention_router.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import concurrent.futures import hashlib import json import os @@ -463,24 +464,47 @@ def dispatched_agents( artifact_cache = ( ledger_artifact_cache if ledger_artifact_cache is not None else {} ) + + def _fetch_agent(agent: str) -> None: + """Fetch and cache the exact-name artifact lookup for one agent.""" + artifact_name = agent_ledger_artifact_name(request, agent) + response = dispatch_client.request( + [ + LEDGER_ARTIFACTS_ENDPOINT, + "-X", + "GET", + "-f", + f"name={artifact_name}", + "-f", + "per_page=100", + ] + ) + artifact_cache[artifact_name] = bool( + _artifact_records(response, expected_name=artifact_name) + ) + + agents_to_fetch = [ + agent + for agent in candidates + if agent_ledger_artifact_name(request, agent) not in artifact_cache + ] + if len(agents_to_fetch) <= 1: + for agent in agents_to_fetch: + _fetch_agent(agent) + else: + # Bounded concurrency for an otherwise-sequential N+1 network fetch. + # list(executor.map(...)) already blocks until every submitted call + # finishes (or raises) before this function proceeds, so shutdown's + # own wait has nothing left to wait for on the success path. + executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + try: + list(executor.map(_fetch_agent, agents_to_fetch)) + finally: + executor.shutdown(wait=False, cancel_futures=True) + for agent in candidates: artifact_name = agent_ledger_artifact_name(request, agent) - if artifact_name not in artifact_cache: - response = dispatch_client.request( - [ - LEDGER_ARTIFACTS_ENDPOINT, - "-X", - "GET", - "-f", - f"name={artifact_name}", - "-f", - "per_page=100", - ] - ) - artifact_cache[artifact_name] = bool( - _artifact_records(response, expected_name=artifact_name) - ) - if artifact_cache[artifact_name]: + if artifact_cache.get(artifact_name): observed.add(agent) return frozenset(observed) diff --git a/tests/test_agent_mention_router.py b/tests/test_agent_mention_router.py index f3a88a51ee..dde1ef4669 100644 --- a/tests/test_agent_mention_router.py +++ b/tests/test_agent_mention_router.py @@ -744,3 +744,57 @@ def test_load_event_and_main_paths(tmp_path: Path, monkeypatch, capsys) -> None: ) assert module.main(["--event-path", str(valid_path), "--dry-run"]) == 0 assert captured[0][1]["dry_run"] is True + + +def test_dispatched_agents_fetches_multiple_candidates_concurrently() -> None: + """More than one uncached agent uses the bounded thread-pool fetch path.""" + + module = load_module() + request = module.parse_event( + event("@cwl-noema-review @opencode-agent") + ) + assert request is not None + client = FakeClient() + + observed = module.dispatched_agents(request, client) + + assert observed == frozenset() + artifact_calls = [ + args for args, _ in client.calls if args[0].endswith("/actions/artifacts") + ] + assert len(artifact_calls) == 2 + + +def test_dispatched_agents_single_candidate_skips_thread_pool() -> None: + """Exactly one uncached agent stays on the plain sequential path.""" + + module = load_module() + request = module.parse_event(event("@opencode-agent")) + assert request is not None + client = FakeClient() + + observed = module.dispatched_agents(request, client) + + assert observed == frozenset() + assert len(client.calls) == 1 + + +def test_dispatched_agents_reuses_the_caller_owned_cache() -> None: + """A pre-populated cache entry never triggers a redundant API call.""" + + module = load_module() + request = module.parse_event( + event("@cwl-noema-review @opencode-agent") + ) + assert request is not None + client = FakeClient() + cached_name = module.agent_ledger_artifact_name(request, "cwl-noema-review") + + observed = module.dispatched_agents( + request, + client, + ledger_artifact_cache={cached_name: True}, + ) + + assert observed == frozenset({"cwl-noema-review"}) + assert len(client.calls) == 1