diff --git a/docs/legacy-runtime-inventory.md b/docs/legacy-runtime-inventory.md index 07591f1..e60cc24 100644 --- a/docs/legacy-runtime-inventory.md +++ b/docs/legacy-runtime-inventory.md @@ -138,7 +138,7 @@ the same "no exact selector" gate as the four definitions above. | Field | Current behavior | |---|---| | Owner | assembly-economics (coulomb); declaration `checkpoints/EXECUTION_DECLARATION.md`, founder-approved 2026-10-13 | -| Executor | `assembly_checkpoint.run_assembly_checkpoint` runs the tenant's own `checkpoints/run_checkpoint.py` (data via web-fetcher, optional llm-connect assessment) | +| Executor | `assembly_checkpoint.run_assembly_checkpoint` runs the tenant's own `checkpoints/run_checkpoint.py` (data via web-fetcher pinned to tag `v0.2.0`, cloned under `~/.cache/rein-aharness/` unless `WEB_FETCHER_PATH` is set; per-run `--cache` scratch dir outside the checkout; optional llm-connect assessment) | | Schedule | Activity Core cron fires; `checkpoints/schedule.md` is authoritative, and out-of-window fires are successful no-ops | | Repository mutation | Commits only under `checkpoints/` | | Publication | Pushes `HEAD` fast-forward to origin, rebases once, never forces. The `checkpoints/` path grant is enforced only inside rein-aharness code (`PUBLISH_PREFIX`), not by a separately granted capability | diff --git a/rein_aharness/assembly_checkpoint.py b/rein_aharness/assembly_checkpoint.py index dc4a7c4..517dda4 100644 --- a/rein_aharness/assembly_checkpoint.py +++ b/rein_aharness/assembly_checkpoint.py @@ -20,6 +20,7 @@ import os import re import subprocess import sys +import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -29,6 +30,9 @@ from rein_aharness import hub PUBLISH_PREFIX = "checkpoints/" RUNNER = "checkpoints/run_checkpoint.py" EXIT_OUT_OF_WINDOW = 3 +# The tenant runner refuses any other web-fetcher version (assembly-economics 2521955). +WEB_FETCHER_TAG = "v0.2.0" +WEB_FETCHER_URL = "https://forgejo.coulomb.social/coulomb/web-fetcher" class AssemblyCheckpointError(RuntimeError): @@ -47,6 +51,7 @@ class AssemblyCheckpointResult: origin_sha: str = "" head_after: str = "" counts: dict[str, Any] = field(default_factory=dict) + cache_dir: str = "" reason: str | None = None @@ -74,6 +79,33 @@ def _sync_from_origin(repo: Path) -> None: _git(repo, "merge", "--ff-only", f"origin/{branch}", timeout=60) +def ensure_web_fetcher(cache_root: Path | None = None) -> Path: + """Path to a web-fetcher checkout of WEB_FETCHER_TAG, outside any target checkout. + + An explicit WEB_FETCHER_PATH / ASSEMBLY_WEB_FETCHER_PATH wins (the runner still + refuses a wrong version). Otherwise a shallow clone of the tag is kept under the + cache root and re-verified on every run; a checkout that is not exactly at the + tag is refused rather than silently used. + """ + explicit = os.environ.get("WEB_FETCHER_PATH") or os.environ.get("ASSEMBLY_WEB_FETCHER_PATH") + if explicit: + return Path(explicit).expanduser() + root = cache_root or Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "rein-aharness" + dest = root / f"web-fetcher-{WEB_FETCHER_TAG}" + url = os.environ.get("ASSEMBLY_WEB_FETCHER_URL", WEB_FETCHER_URL) + if not dest.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + r = subprocess.run(["git", "clone", "--quiet", "--depth", "1", "--branch", WEB_FETCHER_TAG, url, str(dest)], + capture_output=True, text=True, timeout=300) + if r.returncode != 0: + raise AssemblyCheckpointError(f"web-fetcher {WEB_FETCHER_TAG} clone failed: {r.stderr.strip()[:200]}") + tag = subprocess.run(["git", "-C", str(dest), "describe", "--tags", "--exact-match", "HEAD"], + capture_output=True, text=True, timeout=60).stdout.strip() + if tag != WEB_FETCHER_TAG: + raise AssemblyCheckpointError(f"web-fetcher checkout {dest} is at {tag or 'no tag'}, not {WEB_FETCHER_TAG}") + return dest + + def _publish(repo: Path) -> str: """Push HEAD; on rejection rebase checkpoint-only commits once and retry. Never force.""" branch = _branch(repo) @@ -102,10 +134,11 @@ def run_assembly_checkpoint(target_repo: Path, *, kind: str = "light", report_to if sync and commit: _sync_from_origin(repo) env = dict(os.environ) - env.setdefault("WEB_FETCHER_PATH", str(Path(os.environ.get("ASSEMBLY_WEB_FETCHER_PATH", - "~/web-fetcher")).expanduser())) - proc = subprocess.run([sys.executable, "-B", RUNNER, "--kind", kind], cwd=repo, env=env, - capture_output=True, text=True, timeout=900) + env["WEB_FETCHER_PATH"] = str(ensure_web_fetcher()) + # Per-run fetch cache outside the checkout: replayable, never committed. + result.cache_dir = tempfile.mkdtemp(prefix="assembly-checkpoint-cache-") + proc = subprocess.run([sys.executable, "-B", RUNNER, "--kind", kind, "--cache", result.cache_dir], + cwd=repo, env=env, capture_output=True, text=True, timeout=900) summary = {} for line in reversed(proc.stdout.strip().splitlines()): try: @@ -146,7 +179,7 @@ def _hub(result: AssemblyCheckpointResult, report_to_hub: bool) -> None: return detail = {"repo": "assembly-economics", "date": result.date, "kind": result.kind, "path": result.path, "out_of_window": result.out_of_window, "committed": result.committed, "pushed": result.pushed, - "origin_sha": result.origin_sha, **result.counts, "executor": "rein-aharness"} + "origin_sha": result.origin_sha, "cache_dir": result.cache_dir, **result.counts, "executor": "rein-aharness"} if result.ok: what = "out of window (no-op)" if result.out_of_window else f"recorded {result.path}" hub.post_progress_event(summary=f"Assembly checkpoint {result.date} ({result.kind}): {what}", diff --git a/tests/test_assembly_checkpoint.py b/tests/test_assembly_checkpoint.py index 96fda34..e4a3d80 100644 --- a/tests/test_assembly_checkpoint.py +++ b/tests/test_assembly_checkpoint.py @@ -7,11 +7,15 @@ from pathlib import Path import pytest from rein_aharness import approaches as ap -from rein_aharness.assembly_checkpoint import kind_from_title, run_assembly_checkpoint +from rein_aharness import assembly_checkpoint as ac +from rein_aharness.assembly_checkpoint import (AssemblyCheckpointError, ensure_web_fetcher, kind_from_title, + run_assembly_checkpoint) RUNNER = textwrap.dedent(''' import json, sys, pathlib kind = sys.argv[sys.argv.index("--kind") + 1] + cache = pathlib.Path(sys.argv[sys.argv.index("--cache") + 1]) + assert cache.is_dir() and not cache.resolve().is_relative_to(pathlib.Path.cwd().resolve()) mode = pathlib.Path("checkpoints/MODE").read_text().strip() if mode == "out": print(json.dumps({"status": "out-of-window", "date": "2026-10-12", "kind": kind})); sys.exit(3) @@ -28,6 +32,12 @@ def _git(cwd, *args): subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True) +@pytest.fixture(autouse=True) +def _web_fetcher_path(tmp_path, monkeypatch): + monkeypatch.setenv("WEB_FETCHER_PATH", str(tmp_path / "wf")) + monkeypatch.delenv("ASSEMBLY_WEB_FETCHER_PATH", raising=False) + + @pytest.fixture def repo(tmp_path: Path) -> Path: origin = tmp_path / "origin.git" @@ -84,3 +94,34 @@ def test_label_selects_assembly_checkpoint(): labels=["assembly-checkpoint", "research", "automated"], target_repo="assembly-economics", activity_definition_id="assembly-checkpoint-setup-daily") assert ap.select_approach(OpsRun(**kw)) == ap.APPROACH_ASSEMBLY_CHECKPOINT + + +def _tagged_origin(tmp_path: Path, tag: str) -> Path: + src = tmp_path / "wf-src" + _git(tmp_path, "init", "-q", "-b", "main", str(src)) + _git(src, "config", "user.email", "t@t"), _git(src, "config", "user.name", "t") + (src / "README").write_text("wf") + _git(src, "add", "-A"), _git(src, "commit", "-qm", "init"), _git(src, "tag", tag) + return src + + +def test_ensure_web_fetcher_clones_pinned_tag(tmp_path, monkeypatch): + monkeypatch.delenv("WEB_FETCHER_PATH") + monkeypatch.setenv("ASSEMBLY_WEB_FETCHER_URL", (_tagged_origin(tmp_path, ac.WEB_FETCHER_TAG)).as_uri()) + dest = ensure_web_fetcher(tmp_path / "cache") + assert dest == tmp_path / "cache" / f"web-fetcher-{ac.WEB_FETCHER_TAG}" and (dest / "README").exists() + assert ensure_web_fetcher(tmp_path / "cache") == dest # reused, re-verified + + +def test_ensure_web_fetcher_refuses_drifted_checkout(tmp_path, monkeypatch): + monkeypatch.delenv("WEB_FETCHER_PATH") + monkeypatch.setenv("ASSEMBLY_WEB_FETCHER_URL", (_tagged_origin(tmp_path, ac.WEB_FETCHER_TAG)).as_uri()) + dest = ensure_web_fetcher(tmp_path / "cache") + _git(dest, "config", "user.email", "t@t"), _git(dest, "config", "user.name", "t") + _git(dest, "commit", "-q", "--allow-empty", "-m", "drift") + with pytest.raises(AssemblyCheckpointError, match="not v0.2.0"): + ensure_web_fetcher(tmp_path / "cache") + + +def test_explicit_web_fetcher_path_wins(tmp_path): + assert ensure_web_fetcher(tmp_path / "cache") == tmp_path / "wf"