Files
cleveragents-core/tools/controller/master/local_ci.py
T
drew 58307bbdab refactor(ci-logs): retire the dead legacy {sha}.json cache layer
Follow-up to the B1 unification: with `fetch_pr_failure_logs` now a
view over the `get_ci_logs` bundle, the legacy `{sha}.json` cache had
no readers left. Remove it wholesale rather than leave it orphaned.

- `_ci_logs.py`: delete `_cache_covers_all_current_failures`,
  `_record_failure`, `_read_cache`, `_write_cache`, `cache_path` — all
  zero-caller after B1. `invalidate` re-pointed onto the bundle cache
  (`bundle_cache_path`) so it stays a working API. Module docstring
  rewritten to describe the bundle-as-single-store reality.
- `local_ci.py`: `_write_ci_logs_cache` no longer writes the legacy
  `{sha}.json` — `put_local_bundle` already populates the bundle that
  `fetch_pr_failure_logs` projects, so the MCP tool still sees local
  CI logs. One write path, not two.
- Tests re-pointed onto `bundle_cache_path`; `ruff format` applied.

No behavior change — only dead code removed and the docstring
brought current.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 23:02:07 -04:00

1128 lines
41 KiB
Python

"""Local-CI verdict source — the ``RUN_CI_LOCAL=TRUE`` path.
When the cluster's Forgejo CI is unhealthy (every job dies at the
``actions/checkout`` step on a ``git fetch`` connection-reset), the
controller's ``get_ci_status`` / ``get_failure_logs`` callbacks return
garbage red verdicts and the pipeline cannot make progress. Setting
``RUN_CI_LOCAL=TRUE`` swaps those two callbacks for this module, which
runs the *real* ``.forgejo/workflows/ci.yml`` locally via
``tools/run-ci-full-local.sh`` (``forgejo-runner exec``) and reports
the result. ``forgejo-runner exec`` sources the code from a local
checkout, so it produces real gate verdicts even when the cluster's
checkout path is broken.
Async by necessity
------------------
A full CI run takes minutes; a master tick must not block. So the
verdict is served from an on-disk, per-``(owner, repo, head_sha)`` job
cache:
get_ci_status(owner, repo, head_sha):
- no run for this SHA -> spawn a detached runner, return pending
- run in progress -> return pending
- run finished -> return success / failure
The detached runner (``_worker_main``) checks out ``head_sha`` into a
git worktree off a shared per-repo cache clone, runs
``run-ci-full-local.sh --repo-root <worktree>``, and writes the verdict
+ full log into the run dir. Results persist on disk, so they survive a
master restart and are reused across the ci_gate / ci_status_poll /
prefetch consumers without re-running CI.
CI failure detail
-----------------
The worker parses the forgejo-runner combined log into per-job results
and surfaces the failure detail through the SAME channels the remote
pipeline uses, so the implementer sees local CI's errors exactly as it
sees Forgejo's:
- ``get_ci_status`` returns a combined-status dict with one entry per
CI job, so prefetch's ``ci_summary`` / ``failing_gates`` show the
real per-gate breakdown.
- The worker seeds the unified ``_ci_logs`` bundle
(``/tmp/cleveragents-ci-logs-cache/{head_sha}.full.json``,
``source="local"``) with every job's full log — the same store the
implementer's ``ci_fetch_pr_failure_logs`` MCP tool and the
ci_summary prefetch read for Forgejo CI.
Log tails are curated: forgejo-runner orchestration lines (``⭐ Run``,
``actions/checkout`` setup, ``🏁 Job``, ...) are stripped so the agent
sees the genuine ruff/pytest/mypy output, and the infra-vs-real
classifier never false-matches an ``actions/checkout`` step name as an
infra failure. The full raw run log stays at ``<run_dir>/ci.log``.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import time
from collections.abc import Callable
from contextlib import contextmanager
from pathlib import Path
from .ci_freshness import INFRA_FAILURE_SIGNATURES
logger = logging.getLogger(__name__)
# Where per-(owner,repo,head_sha) run dirs + the shared per-repo cache
# clone live. Env-tunable so a deployment can point it at fast disk.
DEFAULT_RUNS_ROOT = Path(
os.environ.get("CONTROLLER_LOCAL_CI_ROOT", "/tmp/cleveragents-local-ci")
)
# Hard ceiling on how long one local CI run may take before the worker
# gives up and records a failure. The fork's full CI is ~25 min; 60 min
# leaves generous headroom for a cold Docker image pull.
DEFAULT_RUN_TIMEOUT_S: float = float(
os.environ.get("CONTROLLER_LOCAL_CI_TIMEOUT_S", "3600")
)
# Backpressure: at most this many local CI runs in flight at once.
# Default 1 — SERIAL. Each run already parallelizes its own jobs across
# ~13 containers (the workflow's needs-graph), which is the real win.
# Running two *workflows* at once instead races the Docker daemon:
# concurrent container create/remove across ~26 containers produces
# "No such container" / "RWLayer is unexpectedly nil" errors that fail
# jobs spuriously. One workflow at a time mirrors how a cluster runner
# executes CI. Operators with a beefy Docker host can raise
# CONTROLLER_LOCAL_CI_MAX_CONCURRENT.
DEFAULT_MAX_CONCURRENT: int = int(
os.environ.get("CONTROLLER_LOCAL_CI_MAX_CONCURRENT", "1")
)
# How long a finished run dir is kept before the GC sweep removes it.
# Finished runs are retained so a re-poll of the same head_sha is served
# from the on-disk cache instead of re-running CI; past this age that
# reuse is moot (the workflow has long since moved on) and reclaiming
# the disk matters more. Env-tunable.
DEFAULT_RETENTION_S: float = float(
os.environ.get("CONTROLLER_LOCAL_CI_RETENTION_S", "86400")
)
# git credential.helper that sources the Forgejo token from
# ``$FORGEJO_TOKEN`` at fetch time — keeps the token out of
# ``.git/config``. Same pattern as worker/workspace.py's PerPRWorkspace.
_CREDENTIAL_HELPER = (
'!f() { test "$1" = "get" && '
'echo "username=x" && '
'echo "password=${FORGEJO_TOKEN:-}"; }; f'
)
_STATUS_RUNNING = "running"
_STATUS_SUCCESS = "success"
_STATUS_FAILURE = "failure"
# Per-git-op timeout for the clone/fetch/worktree steps.
_GIT_TIMEOUT_S = 300
# Per-job curated-log-tail cap. Matches _ci_logs' own default
# (_DEFAULT_MAX_CHARS_PER_JOB) — enough for the failing assertion +
# stack-trace tail of any gate (ruff / mypy / pytest / nox).
_LOG_TAIL_MAX_CHARS = 4000
# get_ci_status(owner, repo, head_sha) -> Forgejo-shaped combined-status
# dict or None. get_failure_logs(owner, repo, head_sha) -> str.
GetCIStatusCallback = Callable[[str, str, str], dict | None]
GetFailureLogsCallback = Callable[[str, str, str], str]
SpawnCallback = Callable[..., None]
# ─── run-dir helpers ─────────────────────────────────────────────────
def _run_key(owner: str, repo: str, head_sha: str) -> str:
"""Filesystem-safe per-(owner,repo,head_sha) run-dir name."""
safe_owner = owner.replace("/", "_")
safe_repo = repo.replace("/", "_")
return f"{safe_owner}__{safe_repo}__{head_sha[:16]}"
def _read_status(run_dir: Path) -> str | None:
"""Read the run's ``status`` file; None when absent/empty."""
try:
return (run_dir / "status").read_text(encoding="utf-8").strip() or None
except (FileNotFoundError, NotADirectoryError):
return None
except OSError:
return None
def _write_status(run_dir: Path, status: str) -> None:
"""Atomically write the run's ``status`` file (temp + rename) so a
concurrent reader never sees a half-written value."""
tmp = run_dir / "status.tmp"
tmp.write_text(status, encoding="utf-8")
os.replace(tmp, run_dir / "status")
def _append_log(run_dir: Path, text: str) -> None:
try:
with (run_dir / "ci.log").open("a", encoding="utf-8") as fh:
fh.write(text)
except OSError:
logger.warning("local_ci: could not append to %s/ci.log", run_dir)
def _runner_is_dead(run_dir: Path) -> bool:
"""True when the run's detached runner process is gone.
Returns False when no ``pid`` file exists yet (the runner is still
starting) — conservative: only a confirmed-dead process is "dead".
"""
try:
pid = int((run_dir / "pid").read_text(encoding="utf-8").strip())
except (FileNotFoundError, NotADirectoryError, ValueError, OSError):
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return True
except PermissionError:
return False
return False
def _count_running(runs_root: Path) -> int:
"""Count live in-flight runs under ``runs_root`` (status=running and
the runner process still alive). Dead-but-running dirs are skipped
so a crashed run never permanently consumes a concurrency slot."""
try:
children = list(runs_root.iterdir())
except (FileNotFoundError, NotADirectoryError):
return 0
n = 0
for child in children:
if not child.is_dir() or child.name == "_repos":
continue
if _read_status(child) == _STATUS_RUNNING and not _runner_is_dead(child):
n += 1
return n
def any_run_in_flight(runs_root: Path | str | None = None) -> bool:
"""True when at least one local CI run is currently executing
(status=running with a live runner process).
The master's ci_poll_exhaustion sweep consults this: while local CI
is busy, every AWAITING_CI workflow is either being verified now or
queued behind a run (local CI is serial by default), so its wait is
legitimate progress — not a wedge. The poll-exhaustion timer must
not STUCK a workflow whose on-demand verdict is minutes away."""
root = Path(runs_root) if runs_root else DEFAULT_RUNS_ROOT
return _count_running(root) > 0
def _gc_old_runs(runs_root: Path, *, max_age_s: float = DEFAULT_RETENTION_S) -> int:
"""Remove finished run dirs whose last activity is older than
``max_age_s``. Never touches a still-running run nor the shared
``_repos`` cache clone. Best-effort: per-dir errors are logged, not
raised. Returns the count removed."""
try:
children = list(runs_root.iterdir())
except OSError:
return 0
now = time.time()
removed = 0
for child in children:
if not child.is_dir() or child.name == "_repos":
continue
if _read_status(child) == _STATUS_RUNNING and not _runner_is_dead(child):
continue # genuinely in flight — keep
try:
age = now - child.stat().st_mtime
except OSError:
continue
if age < max_age_s:
continue
try:
shutil.rmtree(child)
except OSError as exc:
logger.warning("local_ci: GC could not remove %s: %s", child, exc)
continue
removed += 1
if removed:
logger.info(
"local_ci: GC removed %d finished run dir(s) older than %.0fs",
removed,
max_age_s,
)
return removed
def _status_dict(head_sha: str, state: str, gates: list[dict] | None = None) -> dict:
"""Synthesize a Forgejo-shaped combined-status dict.
``classify_ci_result`` / ``ci_status_poll`` read ``state`` + ``sha``
+ ``statuses``. ``statuses`` MUST be non-empty or the classifier
returns ``no_ci`` (it filters statuses by SHA before anything else).
Every entry deliberately carries NO timestamp so the classifier's
staleness check is a no-op.
``gates`` (set once a run has finished) yields one ``statuses``
entry per CI job, so prefetch's ``ci_summary`` / ``failing_gates``
show the real per-gate breakdown. A pending run has no gates yet —
one synthetic entry keeps ``statuses`` non-empty.
"""
if gates:
statuses = [
{
"context": g.get("context") or "local-ci",
"state": g.get("state") or state,
"description": f"local CI: {g.get('state') or state}",
}
for g in gates
]
else:
statuses = [
{
"context": "local-ci",
"state": state,
"description": f"tools/run-ci-full-local.sh: {state}",
}
]
return {"state": state, "sha": head_sha, "statuses": statuses}
# ─── forgejo-runner log parsing ──────────────────────────────────────
# forgejo-runner (act) prefixes every line "[<workflow>/<job>] ".
_JOB_LINE_RE = re.compile(r"^\[([^\]]+)\]\s?(.*)$")
# act step/job-boundary markers (leading-whitespace-tolerant).
_STEP_START = ""
_STEP_PASS = ""
_STEP_FAIL = ""
_JOB_END = "🏁"
# Lines starting with one of these are act orchestration, not real
# command output — dropped from curated log tails.
_NOISE_MARKERS = (_STEP_START, _STEP_PASS, _STEP_FAIL, _JOB_END, "☁️", "🐳", "💬")
def _is_noise_line(line: str) -> bool:
s = line.lstrip()
if any(s.startswith(m) for m in _NOISE_MARKERS):
return True
low = s.lower()
return "skipping post step" in low or low.startswith("cleaning up")
def _drop_infra_lines(text: str) -> str:
"""Drop any line carrying an infra-failure signature. Defensive
belt-and-suspenders so a curated tail can never false-trip the
infra-vs-real classifier — local CI has no infra failure mode."""
sigs = [s.lower() for s in INFRA_FAILURE_SIGNATURES]
return "\n".join(
ln for ln in text.splitlines() if not any(sig in ln.lower() for sig in sigs)
)
def _truncate_tail(text: str) -> str | None:
text = text.strip()
if not text:
return None
if len(text) > _LOG_TAIL_MAX_CHARS:
text = "...[truncated head]...\n" + text[-_LOG_TAIL_MAX_CHARS:]
return text
def _job_verdict(lines: list[str]) -> str:
"""Per-job verdict from the act '🏁 Job ...' line(s)."""
seen: set[str] = set()
for ln in lines:
s = ln.strip()
if _JOB_END not in s:
continue
if "Job failed" in s:
seen.add("failure")
elif "Job succeeded" in s:
seen.add("success")
elif "Job skipped" in s:
seen.add("skipped")
if "failure" in seen:
return "failure"
if "success" in seen:
return "success"
if "skipped" in seen:
return "skipped"
return "unknown"
def _failing_step_output(lines: list[str]) -> str | None:
"""Curated output of a failed job's failing steps.
Walks the job's lines, buffering each step's output between its
``⭐ Run`` start and its verdict; a ``❌ Failure`` flushes the
buffer (plus the failure header) into the result, a ``✅ Success``
discards it. Orchestration + infra-signature lines are stripped.
Falls back to the whole job body when no per-step ``❌`` is found.
"""
failed: list[str] = []
current: list[str] = []
for ln in lines:
s = ln.lstrip()
if s.startswith(_STEP_START):
current = []
continue
if s.startswith(_STEP_PASS):
current = []
continue
if s.startswith(_STEP_FAIL):
failed.append(s) # "❌ Failure - <step>" header
failed.extend(current)
current = []
continue
if _is_noise_line(ln):
continue
current.append(ln)
text = "\n".join(failed).strip()
if not text:
text = "\n".join(ln for ln in lines if not _is_noise_line(ln)).strip()
return _truncate_tail(_drop_infra_lines(text))
def _fallback_log_tail(log_text: str) -> str | None:
"""Curated tail of the whole run log — used when per-job parsing
finds no recognizable job sections."""
return _truncate_tail(_drop_infra_lines(log_text))
def _parse_forgejo_runner_log(log_text: str) -> list[dict]:
"""Parse a ``forgejo-runner exec`` combined log into per-job results.
Returns ``[{job, context, state, log_tail}]`` — ``state`` is
success / failure / skipped / unknown; ``log_tail`` is the curated
failing-step output for a failed job, else None. An unrecognized
format yields ``[]`` (the caller then falls back to the overall
exit code).
"""
jobs: dict[str, dict] = {}
order: list[str] = []
for raw in log_text.splitlines():
m = _JOB_LINE_RE.match(raw)
if not m:
continue
tag, content = m.group(1), m.group(2)
if "/" not in tag:
continue
workflow, _, job = tag.rpartition("/")
# forgejo-runner pads the [workflow/job] tag with spaces to
# column-align its log output — strip that padding.
workflow, job = workflow.strip(), job.strip()
if not job:
continue
if job not in jobs:
jobs[job] = {"workflow": workflow, "lines": []}
order.append(job)
jobs[job]["lines"].append(content)
results: list[dict] = []
for job in order:
info = jobs[job]
state = _job_verdict(info["lines"])
results.append(
{
"job": job,
"context": f"{info['workflow']} / {job}",
"state": state,
"log_tail": (
_failing_step_output(info["lines"]) if state == "failure" else None
),
}
)
return results
def _per_job_full_logs(log_text: str) -> dict[str, str]:
"""Per-job FULL log text from a forgejo-runner combined log, keyed
by the ``"workflow / job"`` context (the commit-status context
shape). Unlike :func:`_parse_forgejo_runner_log` this keeps the
whole job body, untruncated — for the get_ci_logs bundle."""
jobs: dict[str, list[str]] = {}
for raw in log_text.splitlines():
m = _JOB_LINE_RE.match(raw)
if not m:
continue
tag, content = m.group(1), m.group(2)
if "/" not in tag:
continue
workflow, _, job = tag.rpartition("/")
workflow, job = workflow.strip(), job.strip()
if not job:
continue
jobs.setdefault(f"{workflow} / {job}", []).append(content)
return {ctx: "\n".join(lines) for ctx, lines in jobs.items()}
# ─── per-run result file (result.json) ───────────────────────────────
def _write_result(run_dir: Path, overall: str, jobs: list[dict]) -> None:
"""Atomically write result.json — the internal per-run summary read
by ``get_ci_status`` (per-gate breakdown) + ``get_failure_logs``."""
payload = {"overall": overall, "jobs": jobs}
tmp = run_dir / "result.json.tmp"
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
os.replace(tmp, run_dir / "result.json")
def _read_result(run_dir: Path) -> dict | None:
try:
raw = (run_dir / "result.json").read_text(encoding="utf-8")
except (FileNotFoundError, NotADirectoryError, OSError):
return None
try:
data = json.loads(raw)
except ValueError:
return None
return data if isinstance(data, dict) else None
def _gates_from_result(run_dir: Path) -> list[dict] | None:
"""Per-gate ``[{context, state}]`` from result.json, for the
combined-status ``statuses`` list. None when the run produced no
parsable result."""
result = _read_result(run_dir)
if not result:
return None
jobs = result.get("jobs")
if not isinstance(jobs, list) or not jobs:
return None
return [
{
"context": j.get("context") or "local-ci",
"state": j.get("state") or "unknown",
}
for j in jobs
if isinstance(j, dict)
]
# ─── clone-URL helper ────────────────────────────────────────────────
def default_clone_url(owner: str, repo: str) -> str | None:
"""Build the Forgejo clone URL from ``FORGEJO_URL`` (or a stripped
``FORGEJO_API_BASE``). Returns None when neither yields a usable
base — the caller then refuses to start local-CI mode.
The token is NOT embedded in the URL; the runner configures a
``credential.helper`` sourcing ``$FORGEJO_TOKEN`` instead.
"""
base = os.environ.get("FORGEJO_URL", "").rstrip("/")
if not base:
api = os.environ.get("FORGEJO_API_BASE", "").rstrip("/")
suffix = "/api/v1"
base = api[: -len(suffix)] if api.endswith(suffix) else api
base = base.rstrip("/")
if not base or "://" not in base:
return None
return f"{base}/{owner}/{repo}.git"
# ─── startup preflight ───────────────────────────────────────────────
def resolve_runner_bin(repo_root: str | Path) -> str | None:
"""Locate the ``forgejo-runner`` binary the same way
``run-ci-full-local.sh`` does: ``$FORGEJO_RUNNER_BIN`` → ``PATH`` →
``<repo>/tools/.bin/forgejo-runner``. None when none resolve."""
env_bin = os.environ.get("FORGEJO_RUNNER_BIN", "").strip()
if env_bin:
return env_bin if os.access(env_bin, os.X_OK) else None
on_path = shutil.which("forgejo-runner")
if on_path:
return on_path
local = Path(repo_root) / "tools" / ".bin" / "forgejo-runner"
return str(local) if os.access(local, os.X_OK) else None
def preflight_local_ci(repo_root: str | Path) -> str | None:
"""Verify the host can actually run local CI — the forgejo-runner
binary resolves AND the Docker daemon is reachable. Returns an
operator-facing error string when something is missing, else None.
Called at master startup so a ``RUN_CI_LOCAL`` misconfiguration
fails loudly there instead of silently turning into a red CI
verdict on every PR (the runner ``die``s mid-run otherwise)."""
if resolve_runner_bin(repo_root) is None:
return (
"forgejo-runner binary not found — install it (single Go "
"binary: https://code.forgejo.org/forgejo/runner/releases) "
"then put it on PATH, at tools/.bin/forgejo-runner, or set "
"FORGEJO_RUNNER_BIN"
)
if shutil.which("docker") is None:
return "docker not found on PATH (local CI runs each CI job in a container)"
try:
proc = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=30,
)
except (OSError, subprocess.SubprocessError) as exc:
return f"could not probe the Docker daemon: {exc}"
if proc.returncode != 0:
return "Docker daemon not reachable (`docker info` failed) — is it running?"
return None
# ─── public factory ──────────────────────────────────────────────────
def build_local_ci_callbacks(
*,
clone_url: str,
script_path: str,
repo_root: str,
forgejo_token: str = "",
runs_root: Path | str | None = None,
max_concurrent: int = DEFAULT_MAX_CONCURRENT,
_spawn: SpawnCallback | None = None,
) -> tuple[GetCIStatusCallback, GetFailureLogsCallback]:
"""Build the ``(get_ci_status, get_failure_logs)`` pair backed by
local CI runs.
Args:
clone_url: Forgejo clone URL (no embedded credentials).
script_path: absolute path to ``tools/run-ci-full-local.sh``.
repo_root: the controller repo root — the cwd for the detached
``python -m tools.controller.master.local_ci worker`` spawn.
forgejo_token: the Forgejo token; passed to the runner via
``$FORGEJO_TOKEN`` for the credential helper.
runs_root: override the on-disk run-cache root (tests).
max_concurrent: ceiling on simultaneous local CI runs.
_spawn: injectable runner-spawn for tests; defaults to the
real detached-subprocess spawner.
"""
root = Path(runs_root) if runs_root else DEFAULT_RUNS_ROOT
spawn = _spawn or _default_spawn
def get_ci_status(owner: str, repo: str, head_sha: str) -> dict | None:
if not head_sha:
return None
run_dir = root / _run_key(owner, repo, head_sha)
status = _read_status(run_dir)
# No run for this SHA yet — claim + spawn (respecting the cap).
if status is None and not run_dir.exists():
running = _count_running(root)
if running >= max_concurrent:
logger.info(
"local_ci: %d/%d runs in flight; deferring %s/%s @%s",
running,
max_concurrent,
owner,
repo,
head_sha[:12],
)
return _status_dict(head_sha, "pending")
# A new head_sha — opportunistically GC stale finished runs
# so the on-disk run cache doesn't grow without bound.
_gc_old_runs(root)
try:
run_dir.mkdir(parents=True, exist_ok=False)
except FileExistsError:
# Another tick claimed it between the read and the
# mkdir — fall through to the status re-read below.
pass
else:
_write_status(run_dir, _STATUS_RUNNING)
try:
spawn(
run_dir=run_dir,
owner=owner,
repo=repo,
head_sha=head_sha,
clone_url=clone_url,
script_path=script_path,
repo_root=repo_root,
runs_root=root,
forgejo_token=forgejo_token,
)
logger.info(
"local_ci: spawned CI run for %s/%s @%s",
owner,
repo,
head_sha[:12],
)
except Exception:
logger.exception(
"local_ci: failed to spawn runner for %s", head_sha[:12]
)
_write_status(run_dir, _STATUS_FAILURE)
return _status_dict(head_sha, "failure")
return _status_dict(head_sha, "pending")
# Re-read (covers the claim-race fall-through above).
status = _read_status(run_dir)
if status == _STATUS_SUCCESS:
return _status_dict(head_sha, "success", _gates_from_result(run_dir))
if status == _STATUS_FAILURE:
return _status_dict(head_sha, "failure", _gates_from_result(run_dir))
# status is running (or not yet written). If the runner died
# without recording a verdict, record failure — but re-read
# first: the runner may have written its verdict in the window
# between our status read and the liveness check.
if _runner_is_dead(run_dir):
final = _read_status(run_dir)
if final in (_STATUS_SUCCESS, _STATUS_FAILURE):
return _status_dict(head_sha, final, _gates_from_result(run_dir))
logger.warning(
"local_ci: runner for %s/%s @%s exited without a verdict; "
"recording failure",
owner,
repo,
head_sha[:12],
)
_write_status(run_dir, _STATUS_FAILURE)
return _status_dict(head_sha, "failure")
return _status_dict(head_sha, "pending")
def get_failure_logs(owner: str, repo: str, head_sha: str) -> str:
"""Concatenate the curated log tails of the run's failing jobs.
Read from the run's result.json — the tails are already
stripped of forgejo-runner orchestration + infra-signature
noise, so the infra-vs-real classifier sees genuine error text
and lands on ``fresh_real`` (correct: local CI has no infra
failure mode). Returns "" when the run hasn't finished or had
no failing job.
"""
if not head_sha:
return ""
run_dir = root / _run_key(owner, repo, head_sha)
result = _read_result(run_dir)
if not result:
return ""
chunks: list[str] = []
for job in result.get("jobs") or []:
if not isinstance(job, dict) or job.get("state") != "failure":
continue
tail = job.get("log_tail")
if isinstance(tail, str) and tail.strip():
label = job.get("context") or job.get("job") or "ci"
chunks.append(f"=== {label} ===\n{tail}")
return "\n\n".join(chunks)
return get_ci_status, get_failure_logs
# ─── detached-runner spawn ───────────────────────────────────────────
def _default_spawn(
*,
run_dir: Path,
owner: str,
repo: str,
head_sha: str,
clone_url: str,
script_path: str,
repo_root: str,
runs_root: Path,
forgejo_token: str,
) -> None:
"""Spawn the detached ``worker`` subprocess that actually runs CI.
``start_new_session=True`` puts the runner in its own session so an
ordinary master shutdown doesn't kill an in-flight CI run — the
result lands on disk and is reused after a restart. (An explicit
``--stop`` teardown still reaps it via ``pkill -f``.)
"""
env = dict(os.environ)
if forgejo_token:
env["FORGEJO_TOKEN"] = forgejo_token
args = [
sys.executable,
"-m",
"tools.controller.master.local_ci",
"worker",
"--run-dir",
str(run_dir),
"--owner",
owner,
"--repo",
repo,
"--sha",
head_sha,
"--clone-url",
clone_url,
"--script",
script_path,
"--runs-root",
str(runs_root),
]
out = (run_dir / "runner.out").open("w", encoding="utf-8")
try:
subprocess.Popen(
args,
cwd=repo_root,
env=env,
stdout=out,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
)
finally:
out.close()
# ─── git helpers (runner side) ───────────────────────────────────────
@contextmanager
def _file_lock(lock_path: Path):
"""Coarse advisory lock over the shared per-repo cache clone, held
only for the cheap clone/fetch/worktree steps — NOT the long CI
run."""
import fcntl
lock_path.parent.mkdir(parents=True, exist_ok=True)
fh = lock_path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX)
yield
finally:
try:
fcntl.flock(fh, fcntl.LOCK_UN)
finally:
fh.close()
def _git(args: list[str], *, cwd: Path | None = None) -> None:
"""Run a git command; raise RuntimeError on failure."""
try:
subprocess.run(
["git", *args],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
check=True,
timeout=_GIT_TIMEOUT_S,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"git {' '.join(args)} failed: {exc.stderr.strip()}"
) from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"git {' '.join(args)} timed out ({_GIT_TIMEOUT_S}s)"
) from exc
def _ensure_cache_clone(cache: Path, clone_url: str) -> None:
"""Clone the repo into ``cache`` if absent; configure the
token-sourcing credential helper. Idempotent."""
if (cache / ".git").exists():
return
if cache.exists():
shutil.rmtree(cache)
cache.parent.mkdir(parents=True, exist_ok=True)
_git(
[
"-c",
f"credential.helper={_CREDENTIAL_HELPER}",
"clone",
"--no-single-branch",
clone_url,
str(cache),
]
)
_git(
["config", "--local", "credential.helper", _CREDENTIAL_HELPER],
cwd=cache,
)
def _fetch_all(cache: Path) -> None:
"""Refresh remote refs in the cache clone — branch heads plus PR
head refs (so a fork-origin PR's head SHA is also reachable)."""
_git(["fetch", "origin", "--prune"], cwd=cache)
# PR head refs are best-effort: a same-repo PR head is already a
# branch, but a fork-origin PR's SHA only lives under refs/pull/*.
try:
_git(
["fetch", "origin", "+refs/pull/*/head:refs/remotes/pr/*"],
cwd=cache,
)
except RuntimeError as exc:
logger.info("local_ci: PR-ref fetch skipped (%s)", exc)
def _add_worktree(cache: Path, checkout: Path, head_sha: str) -> None:
"""Create a detached worktree at ``head_sha`` for an isolated
checkout, clearing any residue from a crashed prior run."""
if checkout.exists():
shutil.rmtree(checkout, ignore_errors=True)
_git(["worktree", "prune"], cwd=cache)
_git(
["worktree", "add", "--detach", "--force", str(checkout), head_sha],
cwd=cache,
)
def _remove_worktree(cache: Path, checkout: Path) -> None:
"""Best-effort worktree teardown."""
try:
_git(["worktree", "remove", "--force", str(checkout)], cwd=cache)
except RuntimeError as exc:
logger.info("local_ci: worktree remove failed (%s)", exc)
shutil.rmtree(checkout, ignore_errors=True)
# ─── detached worker entry point ─────────────────────────────────────
def _execute_run(
*,
owner: str,
repo: str,
head_sha: str,
clone_url: str,
script_path: str,
runs_root: Path,
run_dir: Path,
timeout_s: float,
) -> str:
"""Do the actual work: checkout ``head_sha``, run CI, return the
verdict (``success`` / ``failure``)."""
repos_dir = runs_root / "_repos"
cache = repos_dir / f"{owner.replace('/', '_')}__{repo.replace('/', '_')}"
lock_path = repos_dir / f"{owner.replace('/', '_')}__{repo.replace('/', '_')}.lock"
checkout = run_dir / "checkout"
# The lock is held only for the cheap clone/fetch/worktree steps so
# concurrent runs for other PRs aren't blocked by a long CI run.
with _file_lock(lock_path):
_ensure_cache_clone(cache, clone_url)
_fetch_all(cache)
_add_worktree(cache, checkout, head_sha)
try:
rc = _run_ci_script(script_path, checkout, run_dir, timeout_s)
finally:
with _file_lock(lock_path):
_remove_worktree(cache, checkout)
# The per-run $HOME held forgejo-runner's act cache (a multi-
# hundred-MB bolt.db) — needed only while CI runs. Drop it now so
# finished run dirs stay small until the GC sweep removes them.
shutil.rmtree(run_dir / "home", ignore_errors=True)
overall = _STATUS_SUCCESS if rc == 0 else _STATUS_FAILURE
# Parse the run log into per-job results + surface them. Best
# effort — the status verdict is the load-bearing output, so a
# summary failure must not flip the verdict.
try:
_summarize_run(run_dir, head_sha=head_sha, overall=overall)
except Exception:
logger.exception("local_ci: failed to summarize run for %s", head_sha[:12])
return overall
def _summarize_run(run_dir: Path, *, head_sha: str, overall: str) -> None:
"""Parse the run's ci.log into per-job results, then write
result.json (consumed by get_ci_status / get_failure_logs) and the
shared _ci_logs per-SHA cache (consumed by the implementer's CI
MCP tool)."""
try:
log_text = (run_dir / "ci.log").read_text(encoding="utf-8", errors="replace")
except OSError:
log_text = ""
jobs = _parse_forgejo_runner_log(log_text)
if not jobs:
# No recognizable job sections — synthesize one gate from the
# exit code so consumers still get a verdict + (on failure)
# the raw log tail.
jobs = [
{
"job": "ci",
"context": "CI / ci",
"state": overall,
"log_tail": (
_fallback_log_tail(log_text) if overall == _STATUS_FAILURE else None
),
}
]
elif overall == _STATUS_FAILURE and not any(j["state"] == "failure" for j in jobs):
# Exit code says failure but no job parsed as failed — append a
# synthetic failing gate so the implementer still gets detail.
jobs.append(
{
"job": "ci",
"context": "CI / ci",
"state": "failure",
"log_tail": _fallback_log_tail(log_text),
}
)
_write_result(run_dir, overall, jobs)
_write_ci_logs_cache(head_sha, jobs, log_text)
def _write_ci_logs_cache(head_sha: str, jobs: list[dict], log_text: str) -> None:
"""Populate the unified ``get_ci_logs`` bundle from a local CI run
(``source="local"``), so every consumer — the implementer/reviewer
``ci_summary`` AND the implementer's ``ci_fetch_pr_failure_logs``
MCP tool — reads local-CI logs through the same entry point as
Forgejo CI. (B1: the legacy ``{sha}.json`` failing-tail cache is
retired; ``fetch_pr_failure_logs`` is now a view over the bundle.)
Best effort: a failure here only costs a log channel; the
implementer prompt's ``ci_summary`` still works off get_ci_status.
"""
try:
from tools import _ci_logs # type: ignore[import-not-found]
except Exception as exc:
logger.warning(
"local_ci: _ci_logs import failed; CI MCP tool won't see local logs: %s",
exc,
)
return
# The unified get_ci_logs bundle — ALL jobs, full per-job logs
# from ci.log, source="local". This is what the implementer/
# reviewer ci_summary reads (via the get_ci_logs callback) and what
# fetch_pr_failure_logs projects for the MCP tool.
try:
full_by_ctx = _per_job_full_logs(log_text)
bundle_jobs = [
{
"context": j.get("context") or j.get("job") or "ci",
"state": j.get("state"),
"description": "local CI (forgejo-runner exec)",
"log": (
full_by_ctx.get(j.get("context") or "") or j.get("log_tail") or ""
),
}
for j in jobs
]
_ci_logs.put_local_bundle(head_sha, bundle_jobs)
except Exception as exc: # noqa: BLE001
logger.warning("local_ci: get_ci_logs bundle write failed: %s", exc)
def _run_ci_script(
script_path: str,
checkout: Path,
run_dir: Path,
timeout_s: float,
) -> int:
"""Run ``run-ci-full-local.sh --repo-root <checkout>``, teeing the
combined output into ``<run_dir>/ci.log``. Returns the exit code
(non-zero on CI failure or timeout)."""
cmd = ["bash", script_path, "--repo-root", str(checkout)]
# Per-run HOME so concurrent runs don't contend on forgejo-runner's
# act cache: ``~/.cache/actcache/bolt.db`` is single-writer bolt —
# a second concurrent ``forgejo-runner exec`` otherwise dies at
# startup with ``Open(...bolt.db): timeout``. forgejo-runner derives
# the cache path from $HOME and ignores $XDG_CACHE_HOME, so an
# isolated HOME is the only lever.
run_home = run_dir / "home"
run_home.mkdir(parents=True, exist_ok=True)
env = dict(os.environ)
env["HOME"] = str(run_home)
with (run_dir / "ci.log").open("w", encoding="utf-8") as logf:
logf.write(f"[local_ci] {' '.join(cmd)}\n\n")
logf.flush()
try:
proc = subprocess.run(
cmd,
cwd=str(checkout),
stdout=logf,
stderr=subprocess.STDOUT,
timeout=timeout_s,
env=env,
)
except subprocess.TimeoutExpired:
logf.write(f"\n[local_ci] CI run timed out after {timeout_s:.0f}s\n")
return 124
return proc.returncode
def _worker_main(argv: list[str]) -> int:
"""Detached-runner entry point — invoked as
``python -m tools.controller.master.local_ci worker ...``."""
import argparse
parser = argparse.ArgumentParser(prog="local_ci worker")
parser.add_argument("--run-dir", required=True)
parser.add_argument("--owner", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--sha", required=True)
parser.add_argument("--clone-url", required=True)
parser.add_argument("--script", required=True)
parser.add_argument("--runs-root", required=True)
parser.add_argument("--timeout", type=float, default=DEFAULT_RUN_TIMEOUT_S)
args = parser.parse_args(argv)
run_dir = Path(args.run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "pid").write_text(str(os.getpid()), encoding="utf-8")
status = _STATUS_FAILURE
try:
status = _execute_run(
owner=args.owner,
repo=args.repo,
head_sha=args.sha,
clone_url=args.clone_url,
script_path=args.script,
runs_root=Path(args.runs_root),
run_dir=run_dir,
timeout_s=args.timeout,
)
except Exception as exc:
logger.exception("local_ci: worker failed for %s", args.sha[:12])
_append_log(run_dir, f"\n[local_ci] worker error: {exc}\n")
status = _STATUS_FAILURE
finally:
_write_status(run_dir, status)
logger.info("local_ci: run %s -> %s", args.sha[:12], status)
return 0 if status == _STATUS_SUCCESS else 1
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if argv and argv[0] == "worker":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return _worker_main(argv[1:])
print(
"usage: python -m tools.controller.master.local_ci worker "
"--run-dir D --owner O --repo R --sha S --clone-url U "
"--script P --runs-root D",
file=sys.stderr,
)
return 2
__all__ = [
"DEFAULT_MAX_CONCURRENT",
"DEFAULT_RETENTION_S",
"DEFAULT_RUNS_ROOT",
"DEFAULT_RUN_TIMEOUT_S",
"GetCIStatusCallback",
"GetFailureLogsCallback",
"any_run_in_flight",
"build_local_ci_callbacks",
"default_clone_url",
"preflight_local_ci",
"resolve_runner_bin",
]
if __name__ == "__main__":
raise SystemExit(main())