feat(reaper): job-reaper + stale merge-lease reclaim + idempotent merge finalization

Closes the "zombie jobs" incident class: job status was set only inside
the live launcher process, so a process death left jobs.status='running'
forever; at max_concurrency=1 one zombie blocked ALL projects' queue
(self-hosting risk). Adds a background daemon (src/job_reaper.py) with
three-tier liveness (dead-pid streak / known exit_code / max-running
backstop) whose only mutating write is an atomic terminal flip guarded by
WHERE status='running' (no double-process). For exit0 the canonical QG is
the source of truth via gate-driven advance, not "exit0".

Also proactively reclaims stale merge-lease (dead pid OR TTL) via file
delete only (no git ops), and makes merge finalization idempotent
(pr_already_merged guard + up-to-date short-circuit on re-drive).

New jobs.pid column via idempotent _ensure_column (no migration); pid
stamped in launcher._spawn after Popen. Reaper start/stop in lifespan;
"reaper" snapshot in GET /queue. Kill-switches: ORCH_REAPER_ENABLED,
ORCH_REAPER_INTERVAL_S, ORCH_REAPER_DEAD_TICKS, ORCH_REAPER_MAX_RUNNING_S,
ORCH_LEASE_RECLAIM_ENABLED.

Invariants unchanged (AC-13): STAGE_TRANSITIONS, QG_CHECKS registry,
check_branch_mergeable signature/behaviour, BUG-8 rollback, hook exit
codes. restart-safe, never-raise per unit of background work.

Docs: docs/architecture/README.md, CHANGELOG.md, .env.example.
Tests: tests/test_job_reaper.py, tests/test_merge_lease_reclaim.py,
tests/test_merge_gate.py (TC-16), tests/test_merge_gate_race.py (TC-17),
tests/test_queue.py, tests/test_config.py (TC-19/TC-20). 742 passed.

Refs: ORCH-065

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 15:31:37 +00:00
committed by Dev Agent
parent 9f846b5a50
commit 4bebb921ff
15 changed files with 1341 additions and 5 deletions

View File

@@ -338,3 +338,141 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None:
return
except OSError as e:
logger.warning("merge-lease release error for %s: %s", repo, e)
# ---------------------------------------------------------------------------
# ORCH-065: proactive stale/dead merge-lease reclaim (Problem B)
# ---------------------------------------------------------------------------
def pid_alive(pid) -> bool:
"""Return True iff process ``pid`` is alive (``os.kill(pid, 0)`` liveness probe).
Semantics (ADR-001 Р-2, never-raise):
* ``ProcessLookupError`` -> the process is gone -> ``False`` (reclaimable).
* ``PermissionError`` -> the pid exists but is owned by another user ->
``True`` (alive; conservatively do NOT reclaim).
* missing / invalid pid -> ``True`` (conservative: a lease that predates the
pid field, or a malformed pid, is NOT reclaimed on the liveness signal —
the TTL backstop still catches it).
Never raises; any unexpected OS/type error -> conservative ``True``.
"""
if not pid:
return True
try:
os.kill(int(pid), 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except (OSError, ValueError, TypeError):
return True
def _lease_reclaim_applies(repo: str) -> bool:
"""Whether proactive lease-reclaim is REAL for ``repo`` (same scope as merge-gate).
Reuses ``qg.checks._merge_gate_applies`` (``merge_gate_repos`` CSV, else the
self-hosting ``orchestrator``) so reclaim and the gate share one predicate
(ADR-001 Р-2 / FR-2.4). Imported lazily to avoid an import cycle (qg.checks
imports merge_gate lazily inside ``check_branch_mergeable``). Never raises:
any error -> ``False`` (no-op, the safe default).
"""
try:
from .qg.checks import _merge_gate_applies
return _merge_gate_applies(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("lease-reclaim applicability check failed for %s: %s", repo, e)
return False
def reclaim_stale_lease(repo: str) -> bool:
"""Proactively reclaim a dead/stale merge-lease for ``repo`` (ADR-001 Р-2).
Unlike the lazy TTL reclaim inside ``acquire_merge_lease`` (which only fires
when ANOTHER task tries to acquire), this releases the lease as soon as the
holder is provably gone — without waiting for the TTL or a foreign acquire:
* holder pid is dead (``pid_alive`` is False) -> reclaim, OR
* lease age >= ``merge_lock_timeout_s`` (TTL) -> reclaim (AC-7).
A LIVE holder within its TTL is never touched (AC-8 — protects a legitimate
in-flight merge). Reclaim is holder-aware (``release_merge_lease(repo,
branch=holder)``) so it can never delete a lease a different task acquired in
the meantime. Conditional (FR-2.4): real only for ``merge_gate_repos`` /
self-hosting; other repos -> no-op. Kill-switch ``lease_reclaim_enabled``.
Returns True iff a lease was reclaimed. Never raises (AC-9): any read/remove
error is logged and swallowed so a single bad lease never kills the reaper
thread. Does NOT run any git operation — only the lease file is removed.
"""
try:
if not settings.lease_reclaim_enabled:
return False
if not _lease_reclaim_applies(repo):
return False
path = _lease_path(repo)
existing = _read_lease(path)
if existing is None:
return False # no lease (or unreadable -> _read_lease already logged)
holder = existing.get("branch")
pid = existing.get("pid")
age = time.time() - float(existing.get("acquired_at") or 0)
dead = not pid_alive(pid)
expired = age >= settings.merge_lock_timeout_s
if not (dead or expired):
return False # live holder within TTL -> protect legitimate merge
why = f"dead pid={pid}" if dead else f"stale age={age:.0f}s>=TTL"
release_merge_lease(repo, branch=holder)
logger.warning(
"merge-lease for %s reclaimed proactively (%s, holder=%s)",
repo, why, holder,
)
try:
from .notifications import send_telegram
send_telegram(
f"\U0001f527 merge-lease для {repo} освобождён проактивно "
f"({why}, holder={holder})"
)
except Exception as e: # noqa: BLE001 - telegram best-effort, never fatal
logger.warning("lease-reclaim telegram failed for %s: %s", repo, e)
return True
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("reclaim_stale_lease unexpected error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# ORCH-065: idempotent merge finalization guard (Problem C)
# ---------------------------------------------------------------------------
def pr_already_merged(repo: str, branch: str) -> bool:
"""Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2).
A deterministic, read-only guard the merge path consults BEFORE attempting a
(second) merge so a re-driven / reaped task is idempotent: an already-merged
PR -> no-op, never a duplicate merge and never an error. This is the ONLY new
merge-related helper and it does NOT merge — it only READS the PR state via
the existing Gitea client, so it does not introduce duplicate merge logic.
Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=<branch>`` and
reports True when any matching PR has ``merged == True``. Never raises (AC-9):
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
normal gate re-evaluate rather than silently skipping a real merge).
"""
try:
import httpx
owner = settings.gitea_owner
headers = {"Authorization": f"token {settings.gitea_token}"}
resp = httpx.get(
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls",
params={"state": "all", "head": branch},
headers=headers, timeout=_SHORT_TIMEOUT,
)
if resp.status_code != 200:
return False
for pr in resp.json() or []:
if pr.get("merged") is True:
return True
return False
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("pr_already_merged check failed for %s/%s: %s", repo, branch, e)
return False