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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ COPY pyproject.toml README.md ./
COPY src/ src/

RUN pip install --no-cache-dir --prefix=/install \
"agentfield>=0.1.84" \
"agentfield==0.1.126" \
"hax-sdk>=0.2.4" \
"pydantic>=2.0" \
"httpx>=0.27" \
Expand Down
57 changes: 57 additions & 0 deletions src/pr_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hmac
import json
import os
import shutil
import subprocess
import threading
import time
Expand Down Expand Up @@ -114,6 +115,58 @@ def _checkout_pr_branch(target_dir: str, pr_number: int) -> None:
raise ValueError(f"git checkout of PR #{pr_number} (pr-review) failed: {checkout.stderr.strip()}")


def _workspace_mtime(path: str) -> float:
"""Last-touched time of a review workspace.

The directory's own mtime doesn't move on re-review — but every fetch
rewrites ``.git/FETCH_HEAD`` — so take the freshest of the markers.
"""
times: list[float] = []
for candidate in (
path,
os.path.join(path, ".git"),
os.path.join(path, ".git", "FETCH_HEAD"),
):
try:
times.append(os.path.getmtime(candidate))
except OSError:
continue
return max(times) if times else 0.0


def _reap_stale_workspaces(workdir: str, keep: str = "") -> None:
"""Delete review workspaces idle for more than PR_AF_WORKSPACE_TTL_DAYS.

Clones under PR_AF_WORKDIR were never removed, so the persistent volume
grew one checkout per reviewed PR forever (#65). Runs lazily whenever a
managed workspace is resolved — no daemon. The workspace being resolved
for the current review (``keep``) is never touched, and an idle TTL means
a concurrently active workspace has a fresh ``.git/FETCH_HEAD`` and is
skipped. Default 7 days; <= 0 disables.
"""
try:
ttl_days = float(os.getenv("PR_AF_WORKSPACE_TTL_DAYS", "7"))
except ValueError:
ttl_days = 7.0
if ttl_days <= 0 or not os.path.isdir(workdir):
return
cutoff = time.time() - ttl_days * 86400
keep_abs = os.path.abspath(keep) if keep else ""
for name in os.listdir(workdir):
path = os.path.join(workdir, name)
if not os.path.isdir(path) or os.path.islink(path):
continue
if keep_abs and os.path.abspath(path) == keep_abs:
continue
if _workspace_mtime(path) >= cutoff:
continue
print(
f"[PR-AF] Reaping stale workspace (idle > {ttl_days:g}d): {path}",
flush=True,
)
shutil.rmtree(path, ignore_errors=True)


def _resolve_repo(repo_path: str | None, pr_url: str | None) -> str:
workdir = os.getenv("PR_AF_WORKDIR", "/workspaces")
target = repo_path
Expand All @@ -139,6 +192,7 @@ def _resolve_repo(repo_path: str | None, pr_url: str | None) -> str:
workspace_name = f"{repo_name}-pr{pr_number}" if pr_number else repo_name
target_dir = os.path.join(workdir, workspace_name)
os.makedirs(workdir, exist_ok=True)
_reap_stale_workspaces(workdir, keep=target_dir)

clone_url = target
gh_token = os.getenv("GH_TOKEN", "")
Expand Down Expand Up @@ -202,6 +256,7 @@ async def review(
ignore_paths: list[str] | None = None,
hints: list[str] | None = None,
models: dict[str, str] | None = None,
max_concurrent_agents: int | None = None,
max_concurrent_reviewers: int | None = None,
max_coverage_iterations: int | None = None,
max_review_depth: int = 2,
Expand Down Expand Up @@ -232,6 +287,7 @@ async def review(
ignore_paths=ignore_paths or [],
hints=hints or [],
models=models,
max_concurrent_agents=max_concurrent_agents,
max_concurrent_reviewers=max_concurrent_reviewers,
max_coverage_iterations=max_coverage_iterations,
max_review_depth=min(max_review_depth, 3),
Expand Down Expand Up @@ -319,6 +375,7 @@ def _webhook_review_limits() -> dict[str, object]:
"""
limits: dict[str, object] = {}
for env_name, input_key, minimum in (
("PR_AF_MAX_CONCURRENT_AGENTS", "max_concurrent_agents", 1),
("PR_AF_MAX_CONCURRENT_REVIEWERS", "max_concurrent_reviewers", 1),
("PR_AF_MAX_REVIEW_DEPTH", "max_review_depth", 0),
("PR_AF_MAX_COVERAGE_ITERATIONS", "max_coverage_iterations", 1),
Expand Down
17 changes: 17 additions & 0 deletions src/pr_af/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,23 @@ class BudgetConfig(BaseModel):
)

# Concurrency
# Review-wide agent budget: the total number of leaf agent invocations
# (reviewers, obligation verifiers, adversary batches, gates, …) allowed
# in flight at once, across ALL phases — including phases that run
# concurrently (coverage loop ‖ consistency-verify). This is the knob that
# bounds peak opencode-subprocess count and therefore peak memory (#65).
max_concurrent_agents: int = Field(
default_factory=lambda: int(os.getenv("PR_AF_MAX_CONCURRENT_AGENTS", "8"))
)
# Deprecated alias: historically capped only reviewer dimensions. Still
# honored — the effective budget is min(max_concurrent_agents, this).
max_concurrent_reviewers: int = 8

# Cap on consistency-verify obligations (one verifier agent each).
max_consistency_obligations: int = Field(
default_factory=lambda: int(os.getenv("PR_AF_MAX_CONSISTENCY_OBLIGATIONS", "12"))
)

# Inner loop caps (per-reviewer)
max_reference_follows_per_reviewer: int = 3
max_child_spawns_per_reviewer: int = 2
Expand Down Expand Up @@ -256,6 +271,8 @@ def from_input(cls, review_input: ReviewInput) -> ReviewConfig:

config.budget.max_cost_usd = review_input.max_cost_usd
config.budget.max_duration_seconds = review_input.max_duration_seconds
if review_input.max_concurrent_agents is not None:
config.budget.max_concurrent_agents = review_input.max_concurrent_agents
if review_input.max_concurrent_reviewers is not None:
config.budget.max_concurrent_reviewers = review_input.max_concurrent_reviewers
if review_input.max_coverage_iterations is not None:
Expand Down
19 changes: 17 additions & 2 deletions src/pr_af/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@
# reviewers, evidence-extract/verify, adversary, compound, consistency). Cache by
# (abspath, mtime) so a re-checkout (new mtime) invalidates — zero quality cost, just
# eliminates redundant disk reads within a review.
# The cache is process-lifetime and spans reviews/repos, so it is bounded by BYTES,
# not just entry count — 2000 large files could otherwise pin multiple GB (#65).
_FILE_CACHE: dict[tuple[str, float], list[str]] = {}
_FILE_CACHE_BYTES = 0
_FILE_CACHE_MAX_BYTES = 128 * 1024 * 1024
_FILE_CACHE_MAX_ENTRIES = 2000

# Each identifier mentioned by a finding costs one repo-wide `grep` child; finding
# bodies can mention dozens, and extraction runs 10 findings at a time (#65).
_MAX_IDENTIFIERS_PER_FINDING = 8


def _read_file_lines(abspath: str) -> list[str]:
global _FILE_CACHE_BYTES
try:
mtime = os.path.getmtime(abspath)
except OSError:
Expand All @@ -33,9 +43,14 @@ def _read_file_lines(abspath: str) -> list[str]:
lines = handle.read().splitlines(keepends=True)
except OSError:
return []
if len(_FILE_CACHE) > 2000: # bound memory across many repos/files
size = sum(len(line) for line in lines)
if size > _FILE_CACHE_MAX_BYTES: # pathological single file: serve it uncached
return lines
if len(_FILE_CACHE) >= _FILE_CACHE_MAX_ENTRIES or _FILE_CACHE_BYTES + size > _FILE_CACHE_MAX_BYTES:
_FILE_CACHE.clear()
_FILE_CACHE_BYTES = 0
_FILE_CACHE[key] = lines
_FILE_CACHE_BYTES += size
return lines


Expand Down Expand Up @@ -141,7 +156,7 @@ async def _extract_for_finding(finding: ReviewFinding) -> EvidencePackage:
async with semaphore:
normalized_file = _normalize_relative_path(repo_path, finding.file_path)
text_blob = "\n".join([finding.title, finding.body, finding.evidence])
identifiers = _extract_mentioned_identifiers(text_blob)
identifiers = _extract_mentioned_identifiers(text_blob)[:_MAX_IDENTIFIERS_PER_FINDING]

primary_task = asyncio.to_thread(
_read_code_snippet,
Expand Down
Loading
Loading