19dad571bd
Three coordinated changes addressing run-8 finding R8-3 ("PR #30's
1440 comments poison the pipeline via prompt size × chain depth"):
Spec #5 — bounded view + deterministic digest. The prompt and
sentinel now embed at most DEFAULT_MAX_PROMPT_COMMENTS=50 verbatim
comments plus a one-paragraph rollup of the older bot attempt
comments (counts by tier / outcome / failing gates / last success).
_build_comments_section takes the most-recent N (comments[-N:]) not
the oldest — a long-standing bug where the worker on heavy PRs saw
ancient history and missed every recent attempt. Bot status / claim
/ sentinel comments are dropped from the view via author-based
classification (HAL9000 / HAL9001 defaults, FORGEJO_USERNAME /
FORGEJO_REVIEWER_USERNAME env overrides) — a content-only
classifier mis-counted them as "humans" and ballooned the view to
1252 items on the real test case (run-10 inspection).
Spec #5 (R8-5) — persistent comment cache fixes. Seed-on-truncation:
a page-cap-truncated fetch now seeds the cache (clipped but valid)
flagged any_partial_fetch=True. since_cursor replaces wall-clock
fetched_at as the ?since= delta cursor so backfill walks forward
from the newest cached comment instead of skipping the un-fetched
middle. _api_get_paginated gains an opt-in return_truncation=True
shape so the cache can distinguish "transient failure" (don't seed)
from "page cap hit" (seed and backfill next cycle).
Spec #6 — claim-sweep routes through the comment cache.
_claim_runtime._find_newest_claim_at used to paginate every page of
issue comments on every cycle (29 sequential round-trips for #30,
~10+ minutes when Forgejo was slow — see run-9 hang diagnosis). It
now reads from _pr_comments_cache.get_pr_comments and reverse-scans
for the marker with early-exit. get_pr_comments grew optional
owner/repo overrides so callers with a narrower RuntimeContext cfg
(no owner/repo attrs) can share the cache. Fail-safe on
completed=False: when the timeline is incomplete and no marker was
found, return datetime.now() so the sweep keeps the claim this
cycle rather than releasing on partial data.
Run-11 verification (PR #30 end-to-end):
- Dispatcher startup -> first cycle log: 12+ min hang -> 9 s
- pr_comments view len in sentinel: 1252 (run-10) -> 50 (run-11)
- pr_comments_digest populated with full tier/outcome/gates rollup
- data_complete=True; 4 implementer sessions ran cleanly
Tests green: 1603 passed / 3 skipped. New test files:
test_attempt_history.py, test_implementer_prefetch.py. New tests
added in test_pr_comments_cache.py, test_claim_runtime.py,
test_implementer_pr_context_cli.py, test_implementer_prompt_snapshot.py,
test_pr_context_sentinel.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""Persistent on-disk cache for PR/issue comments with delta fetch.
|
|
|
|
What this is
|
|
------------
|
|
|
|
Live test 2026-05-13 showed every implementer cycle on PR #30
|
|
re-fetched 1340+ accumulated comments via the
|
|
``/repos/{owner}/{repo}/issues/{N}/comments`` endpoint, hit the
|
|
20-page pagination cap, truncated the most recent comments off the
|
|
end, and burned ~30s on the fetch alone. Across 5 attempts in a
|
|
single PR cycle that's ~150s of identical work plus stale data.
|
|
|
|
This module caches the comment list to disk and supports a delta
|
|
fetch via Forgejo's ``?since=<ISO timestamp>`` query parameter. The
|
|
common case becomes: 5-10 NEW comments since the last fetch (or
|
|
zero) rather than the full 1340.
|
|
|
|
Cache layout
|
|
------------
|
|
|
|
``/tmp/cleveragents-comment-cache/pr-{N}.json`` — per-PR JSON file.
|
|
Schema::
|
|
|
|
{
|
|
"schema_version": 1,
|
|
"pr_number": 30,
|
|
"fetched_at": "2026-05-13T10:00:00+00:00",
|
|
"since_cursor": "2026-05-13T09:59:00+00:00",
|
|
"comments": [
|
|
{"id": 1, "created_at": "...", "body": "...", ...},
|
|
...
|
|
],
|
|
"any_partial_fetch": false
|
|
}
|
|
|
|
``fetched_at`` is the wall-clock time of the last write — it drives
|
|
the staleness check only. ``since_cursor`` is the ``created_at`` of
|
|
the newest cached comment; it is what we pass as ``?since=`` on the
|
|
next delta fetch, so the delta backfills forward from where the
|
|
cache actually ends. A page-capped seed holds only the OLDEST N
|
|
comments, so its newest cached comment is NOT the newest on the PR —
|
|
using a wall-clock ``?since=`` would skip the un-fetched middle
|
|
forever. The list is append-only by id. ``any_partial_fetch`` is
|
|
True when the cache was seeded from a page-capped fetch and has not
|
|
yet been backfilled to completeness by a later delta.
|
|
|
|
Invalidation
|
|
------------
|
|
|
|
Comments edited or deleted upstream are NOT detected in v1. Two
|
|
defenses:
|
|
|
|
- ``CACHE_MAX_STALENESS_S`` (default 24h) — when the cache is older
|
|
than this, we discard it and do a full refetch. Bounds the
|
|
staleness window.
|
|
- Force-refresh via ``invalidate(pr_number)`` for operator-side
|
|
manual interventions.
|
|
|
|
Edits and deletions are rare in this codebase's PR workflow; the
|
|
v1 trade-off (skip detection, do periodic refresh) is intentional.
|
|
|
|
Concurrency
|
|
-----------
|
|
|
|
The cache file is written atomically (write-to-tmp + rename). Two
|
|
dispatchers reading the SAME PR's comments concurrently each
|
|
compute the same delta and the last writer wins — idempotent. A
|
|
modest amount of duplicate-fetch work is the cost; the alternative
|
|
(file locking) would serialise readers, which is worse.
|
|
|
|
The cache is shared between the reviewer and implementer
|
|
dispatchers because both fetch the same endpoint with the same
|
|
auth and both benefit equally from the cache.
|
|
|
|
Errors
|
|
------
|
|
|
|
Every code path is best-effort. Cache read failures fall through
|
|
to a full fetch. Delta fetch failures fall through to a full
|
|
fetch. Full fetch failures bubble up to the caller as ``(items,
|
|
completed=False)`` — same contract as ``_review_fetch._api_get_paginated``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_TOOLS_DIR = str(Path(__file__).resolve().parent)
|
|
if _TOOLS_DIR not in sys.path:
|
|
sys.path.insert(0, _TOOLS_DIR)
|
|
from _loader import ( # noqa: E402 type: ignore[import-not-found]
|
|
load_sibling as _load_sibling,
|
|
)
|
|
|
|
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
|
|
|
|
_logger = logging.getLogger("pr_comments_cache")
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
# Default cache directory. Override via env var for tests / archive
|
|
# sharing between dispatcher hosts.
|
|
_DEFAULT_CACHE_DIR = Path("/tmp/cleveragents-comment-cache")
|
|
_CACHE_DIR_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_DIR"
|
|
|
|
# 24 hours — when the cache is older than this we discard it and
|
|
# do a full refetch. Bounds the edit/delete staleness window
|
|
# without paying the full-fetch cost every cycle.
|
|
_DEFAULT_MAX_STALENESS_S = 86400
|
|
_STALENESS_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_MAX_STALENESS_S"
|
|
|
|
# Kill switch for tests + operators who want to bypass the cache
|
|
# entirely (e.g. while diagnosing a divergence between cached and
|
|
# upstream state).
|
|
_DISABLE_ENV = "IMPLEMENTER_DISPATCHER_COMMENT_CACHE_DISABLE"
|
|
|
|
|
|
def cache_dir() -> Path:
|
|
"""Resolve the on-disk cache directory."""
|
|
return Path(os.environ.get(_CACHE_DIR_ENV) or str(_DEFAULT_CACHE_DIR))
|
|
|
|
|
|
def cache_path(pr_number: int) -> Path:
|
|
"""Per-PR cache file path."""
|
|
return cache_dir() / f"pr-{int(pr_number)}.json"
|
|
|
|
|
|
def _max_staleness_s() -> int:
|
|
raw = os.environ.get(_STALENESS_ENV)
|
|
if raw:
|
|
try:
|
|
return max(0, int(raw))
|
|
except ValueError:
|
|
pass
|
|
return _DEFAULT_MAX_STALENESS_S
|
|
|
|
|
|
def is_disabled() -> bool:
|
|
raw = os.environ.get(_DISABLE_ENV, "").strip().lower()
|
|
return raw in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _now() -> str:
|
|
return _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
|
|
|
|
def _read_cache(pr_number: int) -> dict[str, Any] | None:
|
|
"""Load the cache file for ``pr_number``. Returns ``None`` on any
|
|
error (missing, malformed, schema-mismatch, stale)."""
|
|
target = cache_path(pr_number)
|
|
if not target.exists():
|
|
return None
|
|
try:
|
|
text = target.read_text(encoding="utf-8")
|
|
payload = json.loads(text)
|
|
except (OSError, ValueError) as exc:
|
|
_logger.warning(
|
|
"comment cache read failed for PR #%s at %s: %s",
|
|
pr_number, target, exc,
|
|
)
|
|
return None
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
return None
|
|
# Staleness check
|
|
try:
|
|
fetched_at = _dt.datetime.fromisoformat(
|
|
payload.get("fetched_at", "")
|
|
)
|
|
age_s = (
|
|
_dt.datetime.now(_dt.timezone.utc) - fetched_at
|
|
).total_seconds()
|
|
if age_s > _max_staleness_s():
|
|
_logger.info(
|
|
"comment cache for PR #%s is %.0fs stale (limit %ds); "
|
|
"discarding for full refetch",
|
|
pr_number, age_s, _max_staleness_s(),
|
|
)
|
|
return None
|
|
except (ValueError, TypeError):
|
|
# Bad timestamp → treat as no-cache
|
|
return None
|
|
if not isinstance(payload.get("comments"), list):
|
|
return None
|
|
return payload
|
|
|
|
|
|
def _write_cache(pr_number: int, payload: dict[str, Any]) -> None:
|
|
"""Atomic write of the cache file."""
|
|
target = cache_path(pr_number)
|
|
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
try:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp.write_text(
|
|
json.dumps(payload, indent=2, default=str),
|
|
encoding="utf-8",
|
|
)
|
|
tmp.replace(target)
|
|
except (OSError, TypeError, ValueError) as exc:
|
|
_logger.warning(
|
|
"comment cache write failed for PR #%s at %s: %s",
|
|
pr_number, target, exc,
|
|
)
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _merge_comments(
|
|
cached: list[dict[str, Any]], delta: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Append ``delta`` to ``cached``, deduplicated by comment ``id``.
|
|
|
|
Forgejo's ``since=`` query is timestamp-based; if a comment was
|
|
created at the boundary timestamp, the server MAY return it in
|
|
both the cached snapshot and the delta. Dedup by id is the
|
|
simplest defence.
|
|
|
|
Preserves the order of ``cached`` (oldest first) and appends
|
|
delta items (also oldest-first within delta) at the tail.
|
|
"""
|
|
seen_ids: set[Any] = set()
|
|
out: list[dict[str, Any]] = []
|
|
for c in cached:
|
|
cid = c.get("id")
|
|
if cid is not None and cid in seen_ids:
|
|
continue
|
|
if cid is not None:
|
|
seen_ids.add(cid)
|
|
out.append(c)
|
|
for c in delta:
|
|
cid = c.get("id")
|
|
if cid is not None and cid in seen_ids:
|
|
continue
|
|
if cid is not None:
|
|
seen_ids.add(cid)
|
|
out.append(c)
|
|
return out
|
|
|
|
|
|
def _newest_cursor(comments: list[dict[str, Any]]) -> str:
|
|
"""Return the ``created_at`` of the newest (last) comment, for use
|
|
as the ``?since=`` delta cursor on the next fetch.
|
|
|
|
The comment list is oldest-first, so the last entry is the newest.
|
|
Returns "" when the list is empty or the newest entry has no
|
|
``created_at`` — the caller falls back to ``fetched_at``.
|
|
|
|
Why this and not ``fetched_at`` (wall-clock): a page-capped seed
|
|
holds only the OLDEST N comments, so the newest *cached* comment
|
|
is not the newest on the PR. ``?since=<newest cached created_at>``
|
|
backfills forward from where the cache actually ends;
|
|
``?since=<wall-clock>`` would skip the un-fetched middle forever.
|
|
"""
|
|
if not comments:
|
|
return ""
|
|
last = comments[-1]
|
|
if isinstance(last, dict):
|
|
return str(last.get("created_at") or "")
|
|
return ""
|
|
|
|
|
|
def get_pr_comments(
|
|
cfg: Any, pr_number: int,
|
|
*,
|
|
owner: str | None = None,
|
|
repo: str | None = None,
|
|
) -> tuple[list[dict[str, Any]], bool]:
|
|
"""Get all comments for the PR, using the on-disk cache for the
|
|
bulk and fetching only the delta.
|
|
|
|
Returns ``(comments, completed)`` — same contract as
|
|
:func:`_review_fetch.fetch_pr_comments`. ``completed`` is True
|
|
when both the cache load AND the delta fetch succeeded
|
|
cleanly; False if either was partial.
|
|
|
|
``owner`` / ``repo`` override the values normally read off ``cfg``.
|
|
The shared comment fetch stack only uses ``cfg.owner`` / ``cfg.repo``
|
|
to build the request path; the HTTP transport layer
|
|
(``_claim_runtime.get``) needs just ``token`` / ``request_timeout_s``
|
|
/ ``api_retries``. Callers whose ``cfg`` is the narrower
|
|
``RuntimeContext`` (e.g. ``_claim_runtime``'s sweep, which uses
|
|
module-level ``REPO_OWNER`` / ``REPO_NAME`` constants) pass owner /
|
|
repo explicitly so they can share the cache without growing their
|
|
cfg shape (spec #6).
|
|
|
|
When the cache is disabled, missing, or stale, falls back to a
|
|
full fetch (same behaviour as the legacy code path) and seeds
|
|
the cache for next time.
|
|
|
|
Seed-on-truncation (R8-5): a full fetch that hits the pagination
|
|
ceiling (``truncated=True``) still seeds the cache — the clipped
|
|
data is valid, just incomplete — marked ``any_partial_fetch=True``.
|
|
The next run's delta fetch (``?since=<since_cursor>``) backfills
|
|
forward from the newest cached comment. Before this fix, a heavy
|
|
PR whose full fetch always truncated NEVER seeded the cache and
|
|
re-paginated from page 1 every single cycle. A genuinely-failed
|
|
fetch (network / 5xx / malformed, ``truncated=False``) still does
|
|
NOT seed — its data is unreliable.
|
|
"""
|
|
use_owner = owner or getattr(cfg, "owner", None)
|
|
use_repo = repo or getattr(cfg, "repo", None)
|
|
base_path = (
|
|
f"/repos/{use_owner}/{use_repo}/issues/{int(pr_number)}/comments"
|
|
)
|
|
if is_disabled():
|
|
# Path-based fetch (no cache layer). Uses the same paginator the
|
|
# cache layer uses, so cfg with only RuntimeContext attrs works.
|
|
items, ok = _review_fetch._api_get_paginated(cfg, base_path)
|
|
return items, ok
|
|
cached = _read_cache(pr_number)
|
|
if cached is None:
|
|
# Cache miss → full fetch. Seed on a clean walk OR a page-cap
|
|
# truncation; skip seeding only on a genuine transient failure
|
|
# (its data is unreliable). ``since_cursor`` is the newest
|
|
# comment we actually have, so a truncated seed's next delta
|
|
# backfills forward instead of from a wall-clock ``?since=``.
|
|
items, completed, truncated = _review_fetch._api_get_paginated(
|
|
cfg, base_path, return_truncation=True,
|
|
)
|
|
if completed or truncated:
|
|
_write_cache(pr_number, {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"pr_number": int(pr_number),
|
|
"fetched_at": _now(),
|
|
"since_cursor": _newest_cursor(items),
|
|
"comments": items,
|
|
"any_partial_fetch": not completed,
|
|
})
|
|
return items, completed
|
|
|
|
# Cache hit → delta fetch forward from the newest cached comment.
|
|
# Fall back to ``fetched_at`` for caches written before the
|
|
# ``since_cursor`` field existed.
|
|
since = cached.get("since_cursor") or cached.get("fetched_at", "")
|
|
path = f"{base_path}?since={since}" if since else base_path
|
|
delta_items, delta_ok = _review_fetch._api_get_paginated(cfg, path)
|
|
|
|
if not delta_ok:
|
|
# Delta fetch was partial. Return the cached bulk + whatever
|
|
# delta we got, but flag ``completed=False`` so the caller
|
|
# marks the data as not-complete. Do NOT overwrite the cache
|
|
# — a partial overwrite would discard cached entries.
|
|
merged = _merge_comments(cached["comments"], delta_items)
|
|
return merged, False
|
|
|
|
# Delta succeeded — merge, persist, return. A clean delta walks to
|
|
# the PR's end, so even a previously-truncated seed is now whole:
|
|
# ``any_partial_fetch`` clears.
|
|
merged = _merge_comments(cached["comments"], delta_items)
|
|
cached_payload = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"pr_number": int(pr_number),
|
|
"fetched_at": _now(),
|
|
"since_cursor": _newest_cursor(merged),
|
|
"comments": merged,
|
|
"any_partial_fetch": False,
|
|
}
|
|
_write_cache(pr_number, cached_payload)
|
|
return merged, True
|
|
|
|
|
|
def invalidate(pr_number: int) -> None:
|
|
"""Force-remove the cache entry for a PR.
|
|
|
|
Used by:
|
|
- The dispatcher's post-session cleanup, optionally on hook
|
|
failures or other "this PR's state moved underneath us" cases.
|
|
- Operator manual interventions via a future
|
|
``tools/clear_comment_cache.py`` script.
|
|
|
|
Idempotent — missing entry is a no-op.
|
|
"""
|
|
target = cache_path(pr_number)
|
|
try:
|
|
target.unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
_logger.warning(
|
|
"comment cache invalidate failed for PR #%s at %s: %s",
|
|
pr_number, target, exc,
|
|
)
|
|
|
|
|
|
__all__ = (
|
|
"SCHEMA_VERSION",
|
|
"cache_dir",
|
|
"cache_path",
|
|
"get_pr_comments",
|
|
"invalidate",
|
|
"is_disabled",
|
|
)
|