0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
973 lines
35 KiB
Python
973 lines
35 KiB
Python
"""Pre-clone a PR's working copy for the auto-agents dispatchers.
|
|
|
|
Shared by the reviewer and implementer dispatchers. Reviewer falls
|
|
back here when the embedded diff is insufficient; implementer needs
|
|
an editable copy under ``/tmp/`` so its worker's ``edit: /tmp/**``
|
|
rule covers the source it must change.
|
|
|
|
Knobs (canonical names; the four shared ones honour their
|
|
``REVIEW_DISPATCHER_*`` legacy spellings with a one-shot
|
|
deprecation warning):
|
|
|
|
- ``DISPATCHER_MIRROR_PATH`` / ``DISPATCHER_GIT_FETCH_TIMEOUT_S`` /
|
|
``DISPATCHER_GIT_CLONE_TIMEOUT_S`` /
|
|
``DISPATCHER_MIRROR_MAX_STALENESS_S`` -- shared mirror & timeouts.
|
|
- ``REVIEW_DISPATCHER_WORKTREE_BASE`` /
|
|
``IMPLEMENTER_DISPATCHER_WORKTREE_BASE`` -- per-kind worktree base
|
|
(path shape ``pr-{n}-{kind}-{run_tag}`` for tail-the-log diagnostics).
|
|
- ``REVIEW_DISPATCHER_DISABLE_PRECLONE`` /
|
|
``IMPLEMENTER_DISPATCHER_DISABLE_PRECLONE`` -- per-kind kill-switch.
|
|
- ``IMPLEMENTER_DISPATCHER_PRECLONE`` -- Phase-3 feature flag,
|
|
default-off until rollout (reviewer has no feature flag).
|
|
|
|
Read-only with respect to upstream. Credential isolation lives in
|
|
:mod:`_pr_clone_creds`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, TypedDict
|
|
|
|
_TOOLS_DIR = str(Path(__file__).resolve().parent)
|
|
if _TOOLS_DIR not in sys.path:
|
|
sys.path.insert(0, _TOOLS_DIR)
|
|
from _loader import load_sibling as _load_sibling # noqa: E402 type: ignore[import-not-found]
|
|
|
|
|
|
_pr_clone_creds = _load_sibling("_pr_clone_creds", "_pr_clone_creds.py")
|
|
_ensure_askpass_script = _pr_clone_creds._ensure_askpass_script
|
|
_git_env = _pr_clone_creds._git_env
|
|
_GIT_ENV_PASSTHROUGH = _pr_clone_creds._GIT_ENV_PASSTHROUGH
|
|
|
|
_logger = logging.getLogger("pr_clone")
|
|
|
|
|
|
# ─── Defaults + legacy-aware env reader ────────────────────────────────────
|
|
|
|
_DEFAULT_MIRROR_PATH = "/tmp/.cleveragents-mirror.git"
|
|
_DEFAULT_FETCH_TIMEOUT_S = 300
|
|
_DEFAULT_CLONE_TIMEOUT_S = 900
|
|
# Mirror staleness is a CACHE, not a freshness guarantee:
|
|
# ``_ensure_mirror`` skips ``git fetch --prune`` when mtime is
|
|
# younger than this. 60s amortises refreshes inside a single
|
|
# dispatch cycle (default 300s).
|
|
_DEFAULT_MIRROR_STALENESS_S = 60
|
|
|
|
_LEGACY_DEPRECATION_LOGGED: set[str] = set()
|
|
|
|
|
|
def _legacy_or_canonical_env(canonical: str, legacy: str) -> str | None:
|
|
"""Read ``canonical``; fall back to ``legacy`` with a one-shot
|
|
deprecation log so operators see the warning once per process,
|
|
not once per call. Returns ``None`` when neither is set."""
|
|
val = os.environ.get(canonical)
|
|
if val:
|
|
return val
|
|
legacy_val = os.environ.get(legacy)
|
|
if legacy_val:
|
|
if legacy not in _LEGACY_DEPRECATION_LOGGED:
|
|
_logger.warning(
|
|
"%s is deprecated and shared between both dispatchers; "
|
|
"please rename to %s",
|
|
legacy,
|
|
canonical,
|
|
)
|
|
_LEGACY_DEPRECATION_LOGGED.add(legacy)
|
|
return legacy_val
|
|
return None
|
|
|
|
|
|
# ─── Per-kind config ───────────────────────────────────────────────────────
|
|
|
|
|
|
class _KindConfig(TypedDict):
|
|
"""Shape of each ``_KIND_CONFIG`` entry. Typed so a typo'd key
|
|
is caught by the type checker, not at runtime."""
|
|
|
|
worktree_base_env: str
|
|
default_worktree_base: str
|
|
disable_env: str
|
|
enable_env: str | None # None → no feature flag, always-enabled
|
|
|
|
|
|
# ``enable_env`` is set only for kinds whose pre-clone is gated
|
|
# behind a Phase-3 rollout flag (default-off until Phase 4 flips
|
|
# it). ``None`` means always-enabled, kill-switch is the only knob.
|
|
_KIND_CONFIG: dict[str, _KindConfig] = {
|
|
"review": {
|
|
"worktree_base_env": "REVIEW_DISPATCHER_WORKTREE_BASE",
|
|
"default_worktree_base": "/tmp/cleveragents-review-worktrees",
|
|
"disable_env": "REVIEW_DISPATCHER_DISABLE_PRECLONE",
|
|
"enable_env": None,
|
|
},
|
|
"implementer": {
|
|
"worktree_base_env": "IMPLEMENTER_DISPATCHER_WORKTREE_BASE",
|
|
"default_worktree_base": "/tmp/cleveragents-implementer-worktrees",
|
|
"disable_env": "IMPLEMENTER_DISPATCHER_DISABLE_PRECLONE",
|
|
"enable_env": "IMPLEMENTER_DISPATCHER_PRECLONE",
|
|
},
|
|
}
|
|
|
|
|
|
def _kind_cfg(kind: str) -> _KindConfig:
|
|
"""Look up ``kind``. A typo'd kind logs ``ERROR`` and falls back
|
|
to ``"review"`` rather than raising so one misconfigured caller
|
|
cannot crash a dispatch cycle. ``ERROR`` (not ``WARNING``) so
|
|
the misroute is loud enough to surface in operator alerting:
|
|
silent fall-back to the reviewer worktree base is hazardous
|
|
enough that the dispatcher contract treats it as a programmer
|
|
bug, not a transient anomaly."""
|
|
if kind not in _KIND_CONFIG:
|
|
_logger.error(
|
|
"unknown kind=%r; expected one of %s; falling back to 'review'",
|
|
kind,
|
|
sorted(_KIND_CONFIG),
|
|
)
|
|
return _KIND_CONFIG["review"]
|
|
return _KIND_CONFIG[kind]
|
|
|
|
|
|
# ─── Paths ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
_FALSY_ENV_VALUES = frozenset({"0", "false", "no", "off"})
|
|
|
|
|
|
def _mirror_path() -> Path:
|
|
return Path(
|
|
_legacy_or_canonical_env(
|
|
"DISPATCHER_MIRROR_PATH", "REVIEW_DISPATCHER_MIRROR_PATH"
|
|
)
|
|
or _DEFAULT_MIRROR_PATH
|
|
)
|
|
|
|
|
|
def _worktree_base_with_cfg(cfg: _KindConfig) -> Path:
|
|
"""Production gate twin: shares one cfg across consumers."""
|
|
return Path(
|
|
os.environ.get(cfg["worktree_base_env"]) or cfg["default_worktree_base"]
|
|
)
|
|
|
|
|
|
def _worktree_base(kind: str = "review") -> Path:
|
|
"""Test/direct-caller wrapper around :func:`_worktree_base_with_cfg`."""
|
|
return _worktree_base_with_cfg(_kind_cfg(kind))
|
|
|
|
|
|
# Janitor minimum age — a worktree younger than this is presumed
|
|
# to belong to a currently in-flight cycle and is left alone. The
|
|
# default matches the OpenCode worker ceiling (1800s = 30 min) so
|
|
# any worktree older than the longest possible cycle is guaranteed
|
|
# to be an orphan. Operators with a different ceiling can override.
|
|
_JANITOR_MIN_AGE_S_DEFAULT = 1800
|
|
_JANITOR_MIN_AGE_ENV = "DISPATCHER_WORKTREE_JANITOR_MIN_AGE_S"
|
|
_JANITOR_DISABLE_ENV = "DISPATCHER_WORKTREE_JANITOR_DISABLE"
|
|
|
|
# Worktree dir name pattern emitted by :func:`prepare_pr_worktree`:
|
|
# ``pr-{N}-{kind}-{8-char-uuid}``. Used by the janitor to filter
|
|
# the worktree base so we never accidentally remove something the
|
|
# operator dropped manually.
|
|
_WORKTREE_NAME_RE = re.compile(r"^pr-\d+-(?:review|implementer)-[0-9a-f]{6,16}$")
|
|
|
|
|
|
def _janitor_min_age_s() -> int:
|
|
raw = os.environ.get(_JANITOR_MIN_AGE_ENV)
|
|
if raw:
|
|
try:
|
|
return max(60, int(raw))
|
|
except ValueError:
|
|
pass
|
|
return _JANITOR_MIN_AGE_S_DEFAULT
|
|
|
|
|
|
def _janitor_disabled() -> bool:
|
|
raw = os.environ.get(_JANITOR_DISABLE_ENV, "").strip().lower()
|
|
return raw in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _is_corrupted_worktree(path: Path) -> bool:
|
|
"""A worktree dir is "corrupted" iff its ``.git`` file is missing
|
|
or unreadable. ``git worktree add`` always writes a ``.git`` file
|
|
inside the worktree pointing at ``<mirror>/worktrees/<name>``;
|
|
a missing or 0-byte ``.git`` means the dir survived but the
|
|
git-side metadata was deleted (typical when the worker
|
|
``rm -rf``'d the wrong path or a SIGKILL interrupted setup).
|
|
"""
|
|
git_link = path / ".git"
|
|
if not git_link.exists():
|
|
return True
|
|
try:
|
|
# ``.git`` is normally a small text file with ``gitdir: <path>``.
|
|
return git_link.stat().st_size == 0
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def prune_orphan_worktrees(
|
|
cfg: Any,
|
|
*,
|
|
kind: str = "implementer",
|
|
min_age_s: int | None = None,
|
|
) -> dict[str, int]:
|
|
"""Sweep stale + corrupted worktrees out of the kind's worktree
|
|
base directory. Called once at dispatcher startup so a previous
|
|
cycle's SIGTERM-orphaned or worker-corrupted worktrees can't
|
|
pollute the next cycle.
|
|
|
|
A worktree is removed iff:
|
|
|
|
- its name matches the canonical ``pr-{N}-{kind}-{tag}`` shape
|
|
(so we never touch an operator's manual scratch dir), AND
|
|
- it is OLDER than ``min_age_s`` (default: the OpenCode worker
|
|
ceiling — anything older than the longest possible cycle must
|
|
be from a dead session), OR
|
|
- its ``.git`` link is missing / zero-byte (corruption: the
|
|
next ``git worktree add`` against the same name would fail).
|
|
|
|
Returns a per-bucket count dict (``{scanned, kept, removed_stale,
|
|
removed_corrupted, removed_failed}``) for telemetry. Idempotent.
|
|
Disable via ``DISPATCHER_WORKTREE_JANITOR_DISABLE=1`` for
|
|
operators who want to do their own sweep.
|
|
"""
|
|
counts = {
|
|
"scanned": 0,
|
|
"kept": 0,
|
|
"removed_stale": 0,
|
|
"removed_corrupted": 0,
|
|
"removed_failed": 0,
|
|
}
|
|
if _janitor_disabled():
|
|
_logger.info("worktree janitor disabled via %s", _JANITOR_DISABLE_ENV)
|
|
return counts
|
|
base = _worktree_base(kind)
|
|
if not base.exists():
|
|
return counts
|
|
threshold_s = min_age_s if min_age_s is not None else _janitor_min_age_s()
|
|
now = time.time()
|
|
env = _git_env(cfg)
|
|
mirror = _mirror_path()
|
|
for entry in sorted(base.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
if not _WORKTREE_NAME_RE.match(entry.name):
|
|
continue
|
|
counts["scanned"] += 1
|
|
corrupted = _is_corrupted_worktree(entry)
|
|
try:
|
|
age_s = now - entry.stat().st_mtime
|
|
except OSError:
|
|
age_s = float("inf") # treat as ancient -> remove
|
|
is_stale = age_s > threshold_s
|
|
if not (corrupted or is_stale):
|
|
counts["kept"] += 1
|
|
continue
|
|
# Remove the git-side bookkeeping first (best-effort; if the
|
|
# mirror is missing or doesn't recognise this worktree, the
|
|
# rmtree below still cleans the orphan dir).
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"--git-dir",
|
|
str(mirror),
|
|
"worktree",
|
|
"remove",
|
|
"--force",
|
|
str(entry),
|
|
],
|
|
env=env,
|
|
check=False,
|
|
timeout=30,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
shutil.rmtree(entry, ignore_errors=True)
|
|
except OSError as e:
|
|
_logger.warning(
|
|
"worktree janitor: rmtree failed for %s: %s",
|
|
entry,
|
|
e,
|
|
)
|
|
counts["removed_failed"] += 1
|
|
continue
|
|
if corrupted:
|
|
counts["removed_corrupted"] += 1
|
|
else:
|
|
counts["removed_stale"] += 1
|
|
_logger.info(
|
|
"worktree janitor: removed %s worktree %s (corrupted=%s, age=%.0fs)",
|
|
kind,
|
|
entry.name,
|
|
corrupted,
|
|
age_s,
|
|
)
|
|
# Final prune to clean up the mirror's bookkeeping for any
|
|
# worktrees we just removed.
|
|
if counts["removed_stale"] or counts["removed_corrupted"]:
|
|
try:
|
|
subprocess.run(
|
|
["git", "--git-dir", str(mirror), "worktree", "prune"],
|
|
env=env,
|
|
check=False,
|
|
timeout=30,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
if counts["scanned"]:
|
|
_logger.info(
|
|
"worktree janitor (%s): %s",
|
|
kind,
|
|
", ".join(f"{k}={v}" for k, v in counts.items()),
|
|
)
|
|
return counts
|
|
|
|
|
|
def _env_truthy(name: str) -> bool:
|
|
return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV_VALUES
|
|
|
|
|
|
def _env_falsy_explicit(name: str) -> bool:
|
|
"""``True`` iff ``name`` is set to one of the explicit falsy
|
|
literals (``0`` / ``false`` / ``no`` / ``off``).
|
|
|
|
Used by :func:`_is_preclone_feature_enabled_with_cfg` to model
|
|
the default-ON / explicit-opt-out semantics introduced in
|
|
Phase 4 (2026-05-10) for the implementer pre-clone gate. Unset
|
|
and empty-string both fall through to the default-ON path so an
|
|
operator who writes ``export IMPLEMENTER_DISPATCHER_PRECLONE=``
|
|
(a common typo for ``unset``) is not silently opted out.
|
|
"""
|
|
return os.environ.get(name, "").strip().lower() in _FALSY_ENV_VALUES
|
|
|
|
|
|
def _is_preclone_disabled_with_cfg(cfg: _KindConfig) -> bool:
|
|
"""Production gate twin: shares one cfg across consumers."""
|
|
return _env_truthy(cfg["disable_env"])
|
|
|
|
|
|
def _is_preclone_feature_enabled_with_cfg(cfg: _KindConfig) -> bool:
|
|
"""Production gate twin: shares one cfg across consumers.
|
|
|
|
Semantics changed in **Phase 4 (2026-05-10)**: the implementer
|
|
pre-clone is now default-ON. Returns ``True`` unless the kind's
|
|
``enable_env`` variable is set to one of the explicit falsy
|
|
literals (``0`` / ``false`` / ``no`` / ``off``). When ``enable_env``
|
|
is ``None`` (i.e. the reviewer kind), the feature is unconditionally
|
|
enabled — only the kill-switch can turn it off.
|
|
|
|
The kill-switch (:func:`_is_preclone_disabled_with_cfg`) is
|
|
checked separately by ``prepare_pr_worktree`` AFTER this gate;
|
|
if either gate refuses, the pre-clone is skipped and the worker
|
|
falls back to ``git-isolator-util``.
|
|
"""
|
|
enable_env = cfg["enable_env"]
|
|
if enable_env is None:
|
|
return True
|
|
# Default-ON: only the explicit falsy literals opt out.
|
|
return not _env_falsy_explicit(enable_env)
|
|
|
|
|
|
def _is_preclone_disabled(kind: str = "review") -> bool:
|
|
"""Test/direct-caller wrapper; the production gate path uses
|
|
:func:`_is_preclone_disabled_with_cfg` to avoid a duplicate
|
|
``_kind_cfg`` lookup."""
|
|
return _is_preclone_disabled_with_cfg(_kind_cfg(kind))
|
|
|
|
|
|
def _is_preclone_feature_enabled(kind: str = "review") -> bool:
|
|
"""Test/direct-caller wrapper; the production gate path uses
|
|
:func:`_is_preclone_feature_enabled_with_cfg`."""
|
|
return _is_preclone_feature_enabled_with_cfg(_kind_cfg(kind))
|
|
|
|
|
|
def _fetch_timeout_s() -> int:
|
|
raw = _legacy_or_canonical_env(
|
|
"DISPATCHER_GIT_FETCH_TIMEOUT_S", "REVIEW_DISPATCHER_GIT_FETCH_TIMEOUT_S"
|
|
)
|
|
return int(raw or _DEFAULT_FETCH_TIMEOUT_S)
|
|
|
|
|
|
def _clone_timeout_s() -> int:
|
|
raw = _legacy_or_canonical_env(
|
|
"DISPATCHER_GIT_CLONE_TIMEOUT_S", "REVIEW_DISPATCHER_GIT_CLONE_TIMEOUT_S"
|
|
)
|
|
return int(raw or _DEFAULT_CLONE_TIMEOUT_S)
|
|
|
|
|
|
def _mirror_max_staleness_s() -> int:
|
|
raw = _legacy_or_canonical_env(
|
|
"DISPATCHER_MIRROR_MAX_STALENESS_S", "REVIEW_DISPATCHER_MIRROR_MAX_STALENESS_S"
|
|
)
|
|
return int(raw or _DEFAULT_MIRROR_STALENESS_S)
|
|
|
|
|
|
# ─── Bare mirror lifecycle ─────────────────────────────────────────────────
|
|
|
|
|
|
# ``FORGEJO_URL`` may point at the API root (REST helpers need it);
|
|
# git clones from the web root, so strip the API suffix here.
|
|
_FORGEJO_API_SUFFIXES = ("/api/v1", "/api")
|
|
|
|
|
|
def _clone_url(cfg: Any) -> str:
|
|
"""Build the HTTPS URL git clones from. Credentials are
|
|
delivered via ``GIT_ASKPASS``, never embedded in the URL."""
|
|
base = str(getattr(cfg, "forgejo_url", "")).rstrip("/")
|
|
for suffix in _FORGEJO_API_SUFFIXES:
|
|
if base.endswith(suffix):
|
|
base = base[: -len(suffix)]
|
|
break
|
|
owner = getattr(cfg, "owner", "")
|
|
repo = getattr(cfg, "repo", "")
|
|
return f"{base}/{owner}/{repo}.git"
|
|
|
|
|
|
def _ensure_mirror(cfg: Any) -> Path | None:
|
|
"""Create the bare mirror on first use, refresh it otherwise.
|
|
Returns the path on success, ``None`` on failure."""
|
|
mirror = _mirror_path()
|
|
env = _git_env(cfg)
|
|
if not mirror.exists():
|
|
mirror.parent.mkdir(parents=True, exist_ok=True)
|
|
url = _clone_url(cfg)
|
|
_logger.info("creating bare mirror at %s from %s", mirror, url)
|
|
try:
|
|
subprocess.run(
|
|
["git", "clone", "--mirror", url, str(mirror)],
|
|
env=env,
|
|
check=True,
|
|
timeout=_clone_timeout_s(),
|
|
capture_output=True,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
_logger.warning("bare-mirror clone failed: %s", exc)
|
|
try:
|
|
if mirror.exists():
|
|
shutil.rmtree(mirror)
|
|
except OSError:
|
|
pass
|
|
return None
|
|
_disable_mirror_push_semantics(mirror, env)
|
|
return mirror
|
|
head = mirror / "HEAD"
|
|
try:
|
|
age = time.time() - head.stat().st_mtime if head.exists() else float("inf")
|
|
except OSError:
|
|
age = float("inf")
|
|
if age >= _mirror_max_staleness_s():
|
|
_logger.info("refreshing bare mirror at %s (age=%.0fs)", mirror, age)
|
|
if not _refresh_mirror_with_retry(mirror, env):
|
|
# Both fetch attempts failed. The mirror is potentially
|
|
# corrupted (exit 128 can mean .git internals are
|
|
# damaged, not just a transient network issue). Force a
|
|
# full re-clone so subsequent worktree-add calls don't
|
|
# silently use stale data. C2 fix (2026-05-13): live
|
|
# test logged ``mirror fetch failed: ... exit status 128``
|
|
# once and continued with the stale mirror; if that had
|
|
# been a real corruption the dispatcher would have kept
|
|
# serving stale heads to every cycle indefinitely.
|
|
_logger.warning(
|
|
"mirror fetch failed twice at %s; forcing full re-clone",
|
|
mirror,
|
|
)
|
|
try:
|
|
shutil.rmtree(mirror)
|
|
except OSError as rm_exc:
|
|
_logger.warning(
|
|
"failed to remove broken mirror at %s "
|
|
"(continuing with stale data): %s",
|
|
mirror,
|
|
rm_exc,
|
|
)
|
|
# Fall through with the stale mirror — better than
|
|
# nothing
|
|
else:
|
|
# Re-clone — recurse once
|
|
return _ensure_mirror(cfg)
|
|
# Defensive: an existing mirror created by an older dispatcher
|
|
# version may still have ``remote.origin.mirror=true``. Idempotent
|
|
# to set it to false again (git just no-ops).
|
|
_disable_mirror_push_semantics(mirror, env)
|
|
return mirror
|
|
|
|
|
|
def _refresh_mirror_with_retry(mirror: Path, env: dict[str, str]) -> bool:
|
|
"""Run ``git fetch --prune origin`` on the bare mirror with one
|
|
retry. Returns True on success, False if both attempts failed.
|
|
|
|
C2 (2026-05-13): the original implementation logged WARN on
|
|
first failure and continued with stale data. A transient
|
|
Forgejo 5xx or DNS hiccup that resolves within seconds was
|
|
indistinguishable from real mirror corruption in the logs.
|
|
The retry path resolves the transient case; the False return
|
|
triggers the caller's force-reclone fallback.
|
|
"""
|
|
for attempt in (1, 2):
|
|
try:
|
|
subprocess.run(
|
|
["git", "--git-dir", str(mirror), "fetch", "--prune", "origin"],
|
|
env=env,
|
|
check=True,
|
|
timeout=_fetch_timeout_s(),
|
|
capture_output=True,
|
|
)
|
|
if attempt > 1:
|
|
_logger.info(
|
|
"mirror fetch succeeded on retry %d at %s",
|
|
attempt,
|
|
mirror,
|
|
)
|
|
return True
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
_logger.warning(
|
|
"mirror fetch attempt %d failed: %s",
|
|
attempt,
|
|
exc,
|
|
)
|
|
return False
|
|
|
|
|
|
def _disable_mirror_push_semantics(mirror: Path, env: dict[str, str]) -> None:
|
|
"""Clear ``remote.origin.mirror=true`` on the bare mirror so worktrees
|
|
derived from it can push refspec-style.
|
|
|
|
``git clone --mirror`` sets ``remote.origin.mirror=true``, which makes
|
|
every push to ``origin`` behave as ``git push --mirror`` (push all
|
|
refs, no refspecs allowed). Worktrees inherit this config, so the
|
|
worker's natural ``git push --force-with-lease origin HEAD:<branch>``
|
|
fails with ``fatal: --mirror can't be combined with refspecs``. The
|
|
worker historically had to discover this and work around it per cycle
|
|
(PR #30 2026-05-13 case: 1 of 2 workarounds was fragile and the
|
|
recovery path lost the local commit).
|
|
|
|
The mirror's ``+refs/*:refs/*`` fetch refspec is sufficient to keep
|
|
``fetch`` behaving like a mirror; the ``mirror=true`` flag only
|
|
governs push semantics, which we never use against the bare mirror
|
|
itself anyway. Setting it to false is safe and idempotent.
|
|
|
|
Errors are logged at WARNING but do not propagate — failure to
|
|
flip this flag only means the worker has to apply its existing
|
|
per-command ``-c remote.origin.mirror=false`` workaround.
|
|
"""
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"--git-dir",
|
|
str(mirror),
|
|
"config",
|
|
"remote.origin.mirror",
|
|
"false",
|
|
],
|
|
env=env,
|
|
check=True,
|
|
timeout=10,
|
|
capture_output=True,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
_logger.warning(
|
|
"failed to clear remote.origin.mirror on %s (worker will "
|
|
"have to use its per-command workaround): %s",
|
|
mirror,
|
|
exc,
|
|
)
|
|
|
|
|
|
# ─── Worktree handle ───────────────────────────────────────────────────────
|
|
|
|
|
|
# Workspace handoff sentinel — see ``_write_workspace_handoff`` and
|
|
# ``tools/implementer_workspace.py`` for the read side. The schema is
|
|
# versioned so the script can refuse a sentinel it doesn't understand
|
|
# rather than handing the worker a half-parsed JSON blob.
|
|
WORKSPACE_HANDOFF_SCHEMA_VERSION = 1
|
|
|
|
|
|
def _workspace_handoff_path(cfg: _KindConfig, pr_number: int) -> Path:
|
|
"""Resolve the on-disk path of the workspace handoff sentinel for
|
|
``pr_number`` under the kind-specific worktree base.
|
|
|
|
Lives alongside the worktree directory (sibling, not child) so the
|
|
sentinel survives if the worktree is removed concurrently and
|
|
so ``ls`` on the worktree base shows both. Leading-dot keeps it
|
|
visually subordinate to the per-PR worktree directories.
|
|
"""
|
|
base = _worktree_base_with_cfg(cfg)
|
|
return base / f".handoff-pr-{int(pr_number)}.json"
|
|
|
|
|
|
def _write_workspace_handoff(
|
|
cfg: _KindConfig,
|
|
*,
|
|
pr_number: int,
|
|
head_sha: str,
|
|
repo_dir: Path,
|
|
branch: str | None,
|
|
kind: str,
|
|
) -> Path | None:
|
|
"""Write the dispatcher → worker workspace sentinel atomically.
|
|
|
|
The sentinel carries the bare minimum a worker needs to skip the
|
|
in-session clone: ``repo_dir`` (the worktree path), ``head_sha``
|
|
(so the worker can sanity-check the worktree matches expectations),
|
|
and ``branch`` (so it can checkout / branch off if needed).
|
|
|
|
Atomic via ``write_text(tmp) + replace(target)`` so a concurrent
|
|
reader never sees a half-written file. Best-effort: returns
|
|
``None`` on any I/O / serialisation failure with a WARNING log; a
|
|
write failure must not block the dispatcher from proceeding (the
|
|
worker can still fall back to ``git-isolator-util``).
|
|
"""
|
|
target = _workspace_handoff_path(cfg, pr_number)
|
|
payload = {
|
|
"schema_version": WORKSPACE_HANDOFF_SCHEMA_VERSION,
|
|
"kind": kind,
|
|
"pr_number": int(pr_number),
|
|
"head_sha": head_sha,
|
|
"branch": branch or "",
|
|
"repo_dir": str(repo_dir),
|
|
"dispatcher_pid": os.getpid(),
|
|
"created_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
}
|
|
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
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 e:
|
|
_logger.warning(
|
|
"workspace handoff write failed for PR #%s at %s: %s",
|
|
pr_number,
|
|
target,
|
|
e,
|
|
)
|
|
# Clean up the .tmp file if it was partially written.
|
|
# Without this, a disk-full / partial-write leaves an
|
|
# orphan ``.handoff-pr-30.json.tmp`` accumulating on a
|
|
# busy dispatcher.
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
return None
|
|
return target
|
|
|
|
|
|
def _delete_workspace_handoff(cfg: _KindConfig, pr_number: int) -> None:
|
|
"""Remove the workspace handoff sentinel for ``pr_number``.
|
|
|
|
Best-effort: missing files are silently ignored (the cleanup
|
|
contract treats every reachable path as "I tried"; the next
|
|
cycle's write will overwrite a stale file anyway).
|
|
"""
|
|
target = _workspace_handoff_path(cfg, pr_number)
|
|
try:
|
|
target.unlink(missing_ok=True)
|
|
except OSError as e:
|
|
_logger.warning(
|
|
"workspace handoff cleanup failed for PR #%s at %s: %s",
|
|
pr_number,
|
|
target,
|
|
e,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class WorktreeHandle:
|
|
"""A pre-cloned working copy for one PR session.
|
|
|
|
``path`` is the worktree the worker can ``read`` (and, for the
|
|
implementer, ``edit``). ``cleanup`` removes it from the
|
|
dispatcher's ``post_session_action`` finally branch. ``kind``
|
|
records which dispatcher prepared the handle so cleanup logs
|
|
can be correlated with the source dispatcher.
|
|
"""
|
|
|
|
path: Path
|
|
mirror: Path
|
|
head_sha: str
|
|
pr_number: int
|
|
kind: str = "review"
|
|
branch: str = ""
|
|
|
|
def cleanup(self, cfg: Any) -> None:
|
|
_logger.info(
|
|
"cleaning up %s worktree at %s for PR #%s",
|
|
self.kind,
|
|
self.path,
|
|
self.pr_number,
|
|
)
|
|
env = _git_env(cfg)
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"--git-dir",
|
|
str(self.mirror),
|
|
"worktree",
|
|
"remove",
|
|
"--force",
|
|
str(self.path),
|
|
],
|
|
env=env,
|
|
check=False,
|
|
timeout=60,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
_logger.warning(
|
|
"worktree remove timed out for %s PR #%s at %s; falling "
|
|
"back to filesystem rmtree",
|
|
self.kind,
|
|
self.pr_number,
|
|
self.path,
|
|
)
|
|
# Belt-and-suspenders: even if `worktree remove` succeeded
|
|
# the mirror sometimes leaves stale bookkeeping. Prune.
|
|
try:
|
|
subprocess.run(
|
|
["git", "--git-dir", str(self.mirror), "worktree", "prune"],
|
|
env=env,
|
|
check=False,
|
|
timeout=60,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
if self.path.exists():
|
|
shutil.rmtree(self.path, ignore_errors=True)
|
|
except OSError:
|
|
pass
|
|
# Remove the workspace handoff sentinel — a stale sentinel
|
|
# pointing at a now-removed worktree is worse than no
|
|
# sentinel because the worker's discover script would
|
|
# happily return a non-existent path. The check inside the
|
|
# script catches this defensively too, but the dispatcher
|
|
# owns the sentinel lifecycle so we clean up explicitly.
|
|
try:
|
|
_delete_workspace_handoff(_kind_cfg(self.kind), self.pr_number)
|
|
except Exception as e: # noqa: BLE001 — never let cleanup raise
|
|
_logger.warning(
|
|
"workspace handoff cleanup raised for %s PR #%s: %s",
|
|
self.kind,
|
|
self.pr_number,
|
|
e,
|
|
)
|
|
|
|
|
|
# ─── Public API ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def prepare_pr_worktree(
|
|
cfg: Any,
|
|
pr_number: int,
|
|
head_sha: str,
|
|
*,
|
|
head_ref: str = "",
|
|
kind: str = "review",
|
|
) -> WorktreeHandle | None:
|
|
"""Clone (or refresh + worktree-add) the PR's head_sha into a
|
|
fresh path under the kind-specific worktree base.
|
|
|
|
``kind`` selects the worktree base, the kill-switch, and the
|
|
optional Phase-3 feature flag. Returns ``None`` when the
|
|
pre-clone is gated off or fails; the caller falls back to
|
|
``git-isolator-util`` in that case.
|
|
"""
|
|
# Resolve cfg ONCE (typo'd kind logs the fall-through ERROR
|
|
# once per call) and normalise ``kind`` to the effective
|
|
# fall-back so path filenames + ``WorktreeHandle.kind`` match
|
|
# the base directory the worktree lives under.
|
|
cfg_entry = _kind_cfg(kind)
|
|
if kind not in _KIND_CONFIG:
|
|
kind = "review"
|
|
if not _is_preclone_feature_enabled_with_cfg(cfg_entry):
|
|
_logger.info(
|
|
"PR #%s %s pre-clone gated off (feature flag %s not set)",
|
|
pr_number,
|
|
kind,
|
|
cfg_entry["enable_env"],
|
|
)
|
|
return None
|
|
if _is_preclone_disabled_with_cfg(cfg_entry):
|
|
_logger.info(
|
|
"PR #%s %s pre-clone disabled via %s; worker will fall back "
|
|
"to git-isolator-util if needed",
|
|
pr_number,
|
|
kind,
|
|
cfg_entry["disable_env"],
|
|
)
|
|
return None
|
|
if not head_sha:
|
|
_logger.warning("PR #%s %s pre-clone skipped: no head_sha", pr_number, kind)
|
|
return None
|
|
mirror = _ensure_mirror(cfg)
|
|
if mirror is None:
|
|
return None
|
|
base = _worktree_base_with_cfg(cfg_entry)
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
# 8-char UUID prevents collisions when two cycles of the same
|
|
# kind hit the same PR.
|
|
run_tag = uuid.uuid4().hex[:8]
|
|
path = base / f"pr-{int(pr_number)}-{kind}-{run_tag}"
|
|
env = _git_env(cfg)
|
|
# Confirm SHA is in mirror; staleness is best-effort. A force-push
|
|
# inside the staleness window can leave ``worktree add`` failing
|
|
# with "fatal: invalid reference" — a targeted fetch closes the race.
|
|
if not _fetch_specific_sha(mirror, head_sha, env):
|
|
return None
|
|
|
|
def _worktree_add() -> tuple[bool, BaseException | None]:
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"--git-dir",
|
|
str(mirror),
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
str(path),
|
|
head_sha,
|
|
],
|
|
env=env,
|
|
check=True,
|
|
timeout=_clone_timeout_s(),
|
|
capture_output=True,
|
|
)
|
|
return True, None
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
return False, e
|
|
|
|
ok, exc = _worktree_add()
|
|
if not ok:
|
|
# First-attempt failure on ``worktree add`` is almost always
|
|
# one of two recoverable conditions: (a) the mirror's
|
|
# ``worktrees/`` bookkeeping has a stale entry for a previously-
|
|
# removed dir (causes "already registered" errors), or (b) the
|
|
# target ``path`` collides with an orphan from a prior run that
|
|
# the startup janitor missed (rare — would mean a recent orphan
|
|
# younger than the janitor's min-age threshold). Both are fixed
|
|
# by ``git worktree prune`` + force-removing the target path, so
|
|
# we retry exactly once before returning None.
|
|
_logger.warning(
|
|
"worktree add for PR #%s @ %s failed (1/2): %s; pruning + retry",
|
|
pr_number,
|
|
head_sha[:12],
|
|
exc,
|
|
)
|
|
try:
|
|
subprocess.run(
|
|
["git", "--git-dir", str(mirror), "worktree", "prune"],
|
|
env=env,
|
|
check=False,
|
|
timeout=30,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
if path.exists():
|
|
try:
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
except OSError:
|
|
pass
|
|
ok, exc = _worktree_add()
|
|
if not ok:
|
|
_logger.warning(
|
|
"worktree add for PR #%s @ %s failed (2/2): %s",
|
|
pr_number,
|
|
head_sha[:12],
|
|
exc,
|
|
)
|
|
return None
|
|
# ``head_ref`` is passed by the caller from its prefetched
|
|
# ``pr_details.head.ref`` — same source the workspace sentinel
|
|
# consumer reads. We previously called ``git for-each-ref`` here
|
|
# to re-derive the branch from the mirror, which added a
|
|
# subprocess + several hundred ms per cycle for data the
|
|
# dispatcher already has. The reviewer call site (which has no
|
|
# head_ref to pass) gets the empty default — its sentinel
|
|
# field stays empty, which is correct for the reviewer's
|
|
# read-only flow.
|
|
_write_workspace_handoff(
|
|
cfg_entry,
|
|
pr_number=int(pr_number),
|
|
head_sha=head_sha,
|
|
repo_dir=path,
|
|
branch=head_ref,
|
|
kind=kind,
|
|
)
|
|
return WorktreeHandle(
|
|
path=path,
|
|
mirror=mirror,
|
|
head_sha=head_sha,
|
|
pr_number=int(pr_number),
|
|
kind=kind,
|
|
branch=head_ref or "",
|
|
)
|
|
|
|
|
|
def _fetch_specific_sha(mirror: Path, head_sha: str, env: dict[str, str]) -> bool:
|
|
"""Ensure ``head_sha`` exists in the bare mirror, fetching it if
|
|
necessary. Returns True iff the SHA is present after the call.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"git",
|
|
"--git-dir",
|
|
str(mirror),
|
|
"cat-file",
|
|
"-e",
|
|
f"{head_sha}^{{commit}}",
|
|
],
|
|
env=env,
|
|
check=False,
|
|
timeout=10,
|
|
capture_output=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
result = None # treat as "missing", attempt the fetch below
|
|
if result is not None and result.returncode == 0:
|
|
return True
|
|
_logger.info("mirror missing %s; targeted fetch", head_sha[:12])
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"--git-dir",
|
|
str(mirror),
|
|
"fetch",
|
|
"origin",
|
|
head_sha,
|
|
],
|
|
env=env,
|
|
check=True,
|
|
timeout=_fetch_timeout_s(),
|
|
capture_output=True,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
_logger.warning("targeted fetch for %s failed: %s", head_sha[:12], exc)
|
|
return False
|
|
return True
|
|
|
|
|
|
__all__ = ("WorktreeHandle", "prepare_pr_worktree", "prune_orphan_worktrees")
|