"""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", )