8ed4b96b1a
Folds B1-B4 + C2-C5 from the post-live-test plan into one commit:
B1 — npx tsx pre-warm in dispatchers-launcher.sh closes the cold-cache
30s AbortSignal timeout that killed both dispatchers' first cycle.
B2 — per-tier worker timeout
(IMPLEMENTER_DISPATCHER_WORKER_TIMEOUT_TIER_{N}_SECONDS) lets Tier 1
(qwen-large) and Tier 2 (kimi) get more wallclock than gpt-5-mini;
floor 60s.
B3 — _rebuild_prompt_from_cached_result skips the full prefetch on
tier transitions (worktree-reset puts everything back at the
prefetched head_sha, so the prefetch result + det_sections don't
change). Saves ~7 min per tier transition on comment-heavy PRs.
B4 — git-commit-util.md documents the FORBIDDEN naive recovery
pattern (git fetch && git reset --hard) that lost PR #30 attempt
3's real fix in the live test. Two correct paths now spelled out:
--force-with-lease=<branch>:<old-remote-sha> or stash+rebase+pop.
C2 — _pr_clone._refresh_mirror_with_retry adds one retry on git
fetch failure and force-reclones the bare mirror if both attempts
fail. Previously a single exit 128 logged WARN and continued with
stale data forever.
C3 — in-flight turn markers (asterisk suffix on input/output token
counts) in the per-turn log when completed=False. The archived
turn dict's completed field was already there; the log now surfaces
it. Sub-agent timeout archiving was already correct via
_archive_subagent_tree.
C4 — new module _recent_push_cache.py records per-PR push events
(head_sha + timestamp + cycle metadata). Prefetch surfaces in the
sentinel under recent_implementer_push (with --field accessor)
when the cached push matches the PR's current head_sha within
1h. Prevents the "dispatcher re-cycles right after pushing,
worker re-does the same compliance work" failure mode from
PR #28 cycle 2 in the live test.
C5 (replaces C1) — new module _pr_comments_cache.py wraps
_review_fetch.fetch_pr_comments with disk-backed delta-fetch
semantics. PR #30's 1340+ comment fetch (which previously took
~30s and hit the 20-page pagination cap) now becomes a 5-10 item
delta. Cache is per-PR, shared between reviewer + implementer
dispatchers, has 24h staleness bound, kill-switch via
IMPLEMENTER_DISPATCHER_COMMENT_CACHE_DISABLE=1.
Tests: 1484 passed, 3 skipped (+20 from 49a28b5c). New test files
test_pr_comments_cache.py (16 tests) and test_recent_push_cache.py
(7 tests); new TestPerTierWorkerTimeout class (4 tests).
ISSUES CLOSED: #30 #28
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
5.4 KiB
Python
179 lines
5.4 KiB
Python
"""Per-PR record of the dispatcher's most recent push event.
|
|
|
|
C4 (2026-05-13): a PR that's still in failing-CI state may be picked
|
|
up by the implementer again on the next cycle. If a prior cycle just
|
|
pushed a fix (head_sha_advanced=True), the new cycle's worker should
|
|
know that — otherwise it re-does the same compliance work and emits
|
|
``resolved`` thinking it just fixed the same problem (PR #28 cycle 2
|
|
2026-05-13 failure mode).
|
|
|
|
Storage: per-PR JSON file under ``/tmp/cleveragents-recent-push-cache/``.
|
|
Each record carries:
|
|
|
|
- ``pushed_at`` — ISO timestamp of the push
|
|
- ``pushed_head_sha`` — the SHA the dispatcher pushed
|
|
- ``cycle_id`` — the producing cycle's id (for cross-reference with
|
|
telemetry rows)
|
|
- ``terminal_state`` — terminal_state from that cycle
|
|
- ``outcome`` — outcome string from that cycle
|
|
|
|
The dispatcher's prefetch reads this record and surfaces it to the
|
|
worker via the sentinel's ``recent_implementer_push`` field when the
|
|
record's ``pushed_head_sha`` matches the PR's current head_sha AND
|
|
the push was within the staleness window (default 1 hour).
|
|
|
|
Stale records are discarded — a push from 25h ago shouldn't bias the
|
|
current cycle's decision-making.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_logger = logging.getLogger("recent_push_cache")
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
_DEFAULT_CACHE_DIR = Path("/tmp/cleveragents-recent-push-cache")
|
|
_CACHE_DIR_ENV = "IMPLEMENTER_DISPATCHER_RECENT_PUSH_CACHE_DIR"
|
|
|
|
# 1 hour: if the cached push is older than this, the worker
|
|
# probably shouldn't be biased by it — either CI has had time to
|
|
# react (in which case the new cycle's CI state is the signal) or
|
|
# something else pushed in between.
|
|
_DEFAULT_MAX_AGE_S = 3600
|
|
_MAX_AGE_ENV = "IMPLEMENTER_DISPATCHER_RECENT_PUSH_MAX_AGE_S"
|
|
|
|
|
|
def cache_dir() -> Path:
|
|
return Path(os.environ.get(_CACHE_DIR_ENV) or str(_DEFAULT_CACHE_DIR))
|
|
|
|
|
|
def cache_path(pr_number: int) -> Path:
|
|
return cache_dir() / f"pr-{int(pr_number)}.json"
|
|
|
|
|
|
def _max_age_s() -> int:
|
|
raw = os.environ.get(_MAX_AGE_ENV)
|
|
if raw:
|
|
try:
|
|
return max(0, int(raw))
|
|
except ValueError:
|
|
pass
|
|
return _DEFAULT_MAX_AGE_S
|
|
|
|
|
|
def _now() -> str:
|
|
return _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
|
|
|
|
def record_push(
|
|
pr_number: int,
|
|
pushed_head_sha: str,
|
|
*,
|
|
cycle_id: str = "",
|
|
terminal_state: str = "",
|
|
outcome: str = "",
|
|
) -> None:
|
|
"""Write a per-PR record of the push event. Called from the
|
|
dispatcher's post-session action when head_sha_advanced=True."""
|
|
if not pushed_head_sha:
|
|
return
|
|
target = cache_path(int(pr_number))
|
|
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
payload = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"pr_number": int(pr_number),
|
|
"pushed_at": _now(),
|
|
"pushed_head_sha": pushed_head_sha,
|
|
"cycle_id": cycle_id,
|
|
"terminal_state": terminal_state,
|
|
"outcome": outcome,
|
|
}
|
|
try:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp.write_text(
|
|
json.dumps(payload, indent=2), encoding="utf-8",
|
|
)
|
|
tmp.replace(target)
|
|
except (OSError, TypeError, ValueError) as exc:
|
|
_logger.warning(
|
|
"recent-push cache write failed for PR #%s: %s",
|
|
pr_number, exc,
|
|
)
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def lookup_recent_push(
|
|
pr_number: int, current_head_sha: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Return the cached push record IF it matches the current head
|
|
AND is within the staleness window. Otherwise ``None``.
|
|
|
|
Matching on head_sha is what makes this useful: the cached push
|
|
is only relevant if the PR's HEAD is still the SHA we pushed.
|
|
A subsequent human force-push or merge invalidates the record.
|
|
"""
|
|
target = cache_path(int(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(
|
|
"recent-push cache read failed for PR #%s: %s",
|
|
pr_number, exc,
|
|
)
|
|
return None
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
return None
|
|
if payload.get("pushed_head_sha") != current_head_sha:
|
|
# The PR's HEAD has moved since we pushed; the record is
|
|
# no longer relevant.
|
|
return None
|
|
try:
|
|
pushed_at = _dt.datetime.fromisoformat(payload.get("pushed_at", ""))
|
|
age_s = (
|
|
_dt.datetime.now(_dt.timezone.utc) - pushed_at
|
|
).total_seconds()
|
|
if age_s > _max_age_s():
|
|
return None
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return payload
|
|
|
|
|
|
def invalidate(pr_number: int) -> None:
|
|
"""Remove the per-PR record. Called when a cycle ends with a
|
|
new push (which will write a fresh record) or when the
|
|
dispatcher knows the record is stale (e.g. on cleanup).
|
|
Idempotent."""
|
|
target = cache_path(int(pr_number))
|
|
try:
|
|
target.unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
_logger.warning(
|
|
"recent-push cache invalidate failed for PR #%s: %s",
|
|
pr_number, exc,
|
|
)
|
|
|
|
|
|
__all__ = (
|
|
"SCHEMA_VERSION",
|
|
"cache_dir",
|
|
"cache_path",
|
|
"record_push",
|
|
"lookup_recent_push",
|
|
"invalidate",
|
|
)
|