Files
cleveragents-core/tools/_pr_clone.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
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>
2026-05-20 00:09:17 -04:00

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