Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@
## 2026-08-29 - [λŒ€μš©λŸ‰ ν…μŠ€νŠΈ μŠ€μΊ” μ‹œ μ •κ·œν‘œν˜„μ‹ λŒ€μ‹  λ„€μ΄ν‹°λΈŒ λ©”μ„œλ“œ ν™œμš©]
**Learning:** `scripts/ci/opencode_review_normalize_output.py`의 라벨 μŠ€μΊλ‹ λ£¨ν”„μ—μ„œ κΈ΄ LLM 리뷰 ν…μŠ€νŠΈλ₯Ό λŒ€μƒμœΌλ‘œ `pattern.finditer()`λ₯Ό ν˜ΈμΆœν•˜λŠ” νŒ¨ν„΄μ΄ μžˆμ—ˆμŠ΅λ‹ˆλ‹€. 마이크둜 벀치마크 κ²°κ³Ό, λ‹¨μˆœ λ¬Έμžμ—΄ λ§€μΉ­μ—μ„œλŠ” λ„€μ΄ν‹°λΈŒ `str.find()`와 `while` 루프λ₯Ό μ‘°ν•©ν•˜λŠ” 것이 μ •κ·œν‘œν˜„μ‹ μ‹€ν–‰ μ˜€λ²„ν—€λ“œ 없이 훨씬 λΉ λ₯΄λ‹€λŠ” 것을 ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€.
**Action:** λ‚΄λΆ€ 탐색 λ£¨ν”„μ—μ„œ μ •ν™•νžˆ μΌμΉ˜ν•˜λŠ” λ¦¬ν„°λŸ΄ λ¬Έμžμ—΄(라벨 접두사 λ“±)을 검색할 λ•ŒλŠ” `re.compile(re.escape(string)).finditer()` λŒ€μ‹  κ³ λ„λ‘œ μ΅œμ ν™”λœ Python λ„€μ΄ν‹°λΈŒ `text.find(candidate, index)` λ©”μ„œλ“œλ₯Ό μ‚¬μš©ν•˜μ‹­μ‹œμ˜€. 단, λ¬΄ν•œ 루프λ₯Ό λ°©μ§€ν•˜κΈ° μœ„ν•΄ λ£¨ν”„μ˜ λͺ¨λ“  λΆ„κΈ°μ—μ„œ μΈλ±μŠ€κ°€ μ˜¬λ°”λ₯΄κ²Œ μ§„ν–‰λ˜λ„λ‘ 보μž₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.

## 2026-09-12 - Avoid N+1 API blocking in agent router
**Learning:** The `dispatched_agents` function in `scripts/ci/agent_mention_router.py` was fetching ledger artifacts from the GitHub API synchronously for each agent. This N+1 network/API bottleneck stalled execution when multiple agents were requested.
**Action:** Use `concurrent.futures.ThreadPoolExecutor` bounded by a small `max_workers` to concurrently fetch ledger artifacts for multiple agents, avoiding sequential API delays while keeping the fast serial path for single-item inputs. Also, explicitly instantiate the executor and shut it down in a `finally` block to avoid hang issues.
55 changes: 39 additions & 16 deletions scripts/ci/agent_mention_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
import concurrent.futures
import hashlib
import json
import os
Expand Down Expand Up @@ -383,24 +384,46 @@ 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 exact-name artifacts for a single 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:
executor = concurrent.futures.ThreadPoolExecutor(max_workers=5)
try:
list(executor.map(_fetch_agent, agents_to_fetch))
except Exception: # pragma: no cover
raise
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)

Expand Down
32 changes: 32 additions & 0 deletions tests/test_agent_mention_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,35 @@ 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_concurrent_path() -> None:
"""The dispatched_agents function uses ThreadPoolExecutor for multiple agents."""
module = load_module()
request = module.parse_event(event("@cwl-noema-review @opencode-agent"))
assert request is not None
client = FakeClient()

# We want to force it to use ThreadPoolExecutor by providing multiple agents.
agents = ["cwl-noema-review", "opencode-agent"]

observed = module.dispatched_agents(
request,
dispatch_client=client,
agents=agents,
)
# The fake client returns an empty artifact array, so none are observed as dispatched.
assert observed == frozenset()

# Assert two artifact checks happened
artifact_calls = [c for c in client.calls if c[0][0].endswith("/actions/artifacts")]
assert len(artifact_calls) == 2
Comment on lines +375 to +395

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ” Concurrency test permits serial execution

test_dispatched_agents_concurrent_path checks only results and call counts. A serial implementation passes unchanged, leaving the performance behavior unprotected.

Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.


# Now verify the cache path
cache = {module.agent_ledger_artifact_name(request, "cwl-noema-review"): True}
observed = module.dispatched_agents(
request,
dispatch_client=client,
agents=["cwl-noema-review"],
ledger_artifact_cache=cache
)
assert observed == frozenset({"cwl-noema-review"})
Loading