From c330fcc9d2ecbcaa694609d9be50565df8e1a8ca Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 8 Sep 2026 03:44:45 -0700 Subject: [PATCH] fix: clear stale maker patch at author reset Clear the stale maker patch at author reset while preserving feedback. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .github/capsule-pipeline/capsule.dot | 2 +- tests/test_capsule_author_reset.py | 160 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tests/test_capsule_author_reset.py diff --git a/.github/capsule-pipeline/capsule.dot b/.github/capsule-pipeline/capsule.dot index 9db9bfc..719afc6 100644 --- a/.github/capsule-pipeline/capsule.dot +++ b/.github/capsule-pipeline/capsule.dot @@ -331,7 +331,7 @@ digraph CapsulePipeline { // as rival_reset; the RC-10 purge retires with the reset idiom it // extends). author_reset [shape=parallelogram, class="glue", max_retries=0, - tool_command="BASE=$(cat .ai/base-sha 2>/dev/null); n=$(grep -c '\"gate\": \"round\"' .ai/convergence.jsonl 2>/dev/null); n=$((${n:-0}+1)); DIRT=$(git status --porcelain 2>/dev/null | grep -vE '^.{3}\\.ai(/|$)' | head -20); if [ -n \"$DIRT\" ] || { [ -n \"$BASE\" ] && [ \"$(git rev-parse HEAD)\" != \"$BASE\" ]; }; then dirty=true; mkdir -p .ai/findings; [ -f .ai/findings/author-tree-dirt.md ] || echo '# Finding: the author left the pinned tree dirty (mechanically recovered)' > .ai/findings/author-tree-dirt.md; { echo; echo \"AUTHOR TREE-DIRT (iteration $n): the author lane ended with target_dir not pristine at the pinned base SHA (${BASE:-MISSING .ai/base-sha}); HEAD=$(git rev-parse HEAD). Self-testing fix-shaped edits against the pinned tree is legitimate; leaving them applied is a recorded fact, never silently absorbed. The tree was hard-reset and the reset PROVEN before any measurement. Dirty entries (git status --porcelain, .ai/ excluded, first 20):\"; echo \"$DIRT\"; } >> .ai/findings/author-tree-dirt.md; else dirty=false; fi; git checkout --quiet -- .; git clean -qfd -e .ai; PYCN=$(find . \\( -name .git -o -name .venv -o -name venv \\) -prune -o \\( -name __pycache__ -type d -o -name '*.py[co]' -type f \\) -prune -print 2>/dev/null | grep -c .); find . \\( -name .git -o -name .venv -o -name venv \\) -prune -o \\( -name __pycache__ -type d -o -name '*.py[co]' -type f \\) -prune -print 2>/dev/null | while IFS= read -r p; do rm -rf \"$p\"; done; printf '{\"iteration\": %s, \"gate\": \"author_reset\", \"dirty\": %s, \"pyc_purged\": %s}\\n' \"$n\" \"$dirty\" \"$PYCN\" >> .ai/convergence.jsonl; if [ -n \"$BASE\" ] && [ -z \"$(git status --porcelain 2>/dev/null | grep -vE '^.{3}\\.ai(/|$)')\" ] && [ \"$(git rev-parse HEAD)\" = \"$BASE\" ]; then printf reset_proven; else { echo \"AUTHOR-RESET: RESET NOT PROVEN after the author maker -- porcelain and/or HEAD does not match the pinned base SHA (${BASE:-MISSING .ai/base-sha}) even after a hard reset (git checkout --quiet -- . && git clean -qfd -e .ai). Halting loud: an unreset tree poisons every gate verdict downstream (redgate would measure base+author-edits, not base -- the RC-7 class). Dirty entries (git status --porcelain, first 20):\"; git status --porcelain 2>/dev/null | head -20; } > .ai/gate.log; printf reset_unproven; fi"] + tool_command="BASE=$(cat .ai/base-sha 2>/dev/null); n=$(grep -c '\"gate\": \"round\"' .ai/convergence.jsonl 2>/dev/null); n=$((${n:-0}+1)); DIRT=$(git status --porcelain 2>/dev/null | grep -vE '^.{3}\\.ai(/|$)' | head -20); if [ -n \"$DIRT\" ] || { [ -n \"$BASE\" ] && [ \"$(git rev-parse HEAD)\" != \"$BASE\" ]; }; then dirty=true; mkdir -p .ai/findings; [ -f .ai/findings/author-tree-dirt.md ] || echo '# Finding: the author left the pinned tree dirty (mechanically recovered)' > .ai/findings/author-tree-dirt.md; { echo; echo \"AUTHOR TREE-DIRT (iteration $n): the author lane ended with target_dir not pristine at the pinned base SHA (${BASE:-MISSING .ai/base-sha}); HEAD=$(git rev-parse HEAD). Self-testing fix-shaped edits against the pinned tree is legitimate; leaving them applied is a recorded fact, never silently absorbed. The tree was hard-reset and the reset PROVEN before any measurement. Dirty entries (git status --porcelain, .ai/ excluded, first 20):\"; echo \"$DIRT\"; } >> .ai/findings/author-tree-dirt.md; else dirty=false; fi; git checkout --quiet -- .; git clean -qfd -e .ai; rm -f .ai/hypothesis.patch; PYCN=$(find . \\( -name .git -o -name .venv -o -name venv \\) -prune -o \\( -name __pycache__ -type d -o -name '*.py[co]' -type f \\) -prune -print 2>/dev/null | grep -c .); find . \\( -name .git -o -name .venv -o -name venv \\) -prune -o \\( -name __pycache__ -type d -o -name '*.py[co]' -type f \\) -prune -print 2>/dev/null | while IFS= read -r p; do rm -rf \"$p\"; done; printf '{\"iteration\": %s, \"gate\": \"author_reset\", \"dirty\": %s, \"pyc_purged\": %s}\\n' \"$n\" \"$dirty\" \"$PYCN\" >> .ai/convergence.jsonl; if [ -n \"$BASE\" ] && [ -z \"$(git status --porcelain 2>/dev/null | grep -vE '^.{3}\\.ai(/|$)')\" ] && [ \"$(git rev-parse HEAD)\" = \"$BASE\" ] && [ ! -e .ai/hypothesis.patch ] && [ ! -L .ai/hypothesis.patch ]; then printf reset_proven; else { echo \"AUTHOR-RESET: RESET NOT PROVEN after the author maker -- porcelain and/or HEAD does not match the pinned base SHA (${BASE:-MISSING .ai/base-sha}) even after a hard reset (git checkout --quiet -- . && git clean -qfd -e .ai). Halting loud: an unreset tree poisons every gate verdict downstream (redgate would measure base+author-edits, not base -- the RC-7 class). Dirty entries (git status --porcelain, first 20):\"; git status --porcelain 2>/dev/null | head -20; } > .ai/gate.log; printf reset_unproven; fi"] // GATE: enforces "a publishable capsule pair exists in checkable shape" + // walls the budget (ledger-derived round count; every entry spends). diff --git a/tests/test_capsule_author_reset.py b/tests/test_capsule_author_reset.py new file mode 100644 index 0000000..283070b --- /dev/null +++ b/tests/test_capsule_author_reset.py @@ -0,0 +1,160 @@ +"""Regression coverage for the capsule author-reset scratch-state contract. + +The reset command is extracted from the shipped graph and executed in a disposable +Git repository. It is intentionally never copied into this test: the graph is +the executable source of truth. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import subprocess + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_DOT = REPO_ROOT / ".github" / "capsule-pipeline" / "capsule.dot" + + +def _dot_path() -> Path: + """Allow the red-control invocation to supply pre-change graph bytes.""" + return Path(os.environ.get("CAPSULE_AUTHOR_RESET_DOT", DEFAULT_DOT)) + + +def _author_reset_command() -> str: + dot = _dot_path().read_text(encoding="utf-8") + match = re.search( + r'author_reset\s*\[[^\]]*?tool_command="((?:[^"\\]|\\.)*)"', dot, re.DOTALL + ) + assert match, f"could not extract author_reset tool_command from {_dot_path()}" + return re.sub(r"\\(.)", lambda escaped: escaped.group(1), match.group(1)) + + +def _fixture_env(cwd: Path) -> dict[str, str]: + """Confine git and the extracted shell command to the disposable fixture.""" + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + home = cwd / ".ai" / ".test-home" + home.mkdir(parents=True, exist_ok=True) + xdg_config_home = home / ".config" + xdg_config_home.mkdir(exist_ok=True) + env["HOME"] = str(home) + env["XDG_CONFIG_HOME"] = str(xdg_config_home) + env["GIT_CONFIG_NOSYSTEM"] = "1" + return env + + +def _run(command: str, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["sh", "-c", command], + cwd=cwd, + check=False, + text=True, + capture_output=True, + env=_fixture_env(cwd), + ) + + +def _git(cwd: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=False, + text=True, + capture_output=True, + env=_fixture_env(cwd), + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def _fixture(tmp_path: Path) -> tuple[Path, str, bytes]: + repo = tmp_path / "target" + repo.mkdir() + (repo / "tracked.txt").write_text("base\n", encoding="utf-8") + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "test@example.invalid") + _git(repo, "config", "user.name", "Capsule reset test") + _git(repo, "add", "tracked.txt") + _git(repo, "commit", "-qm", "base") + base = _git(repo, "rev-parse", "HEAD") + + feedback = b"feedback\x00bytes\r\n\xffmust survive exactly\n" + (repo / ".ai" / "feedback").mkdir(parents=True) + (repo / ".ai" / "base-sha").write_text(f"{base}\n", encoding="utf-8") + (repo / ".ai" / "convergence.jsonl").write_text( + '{"iteration": 1, "gate": "round", "shape": "ok"}\n', encoding="utf-8" + ) + (repo / ".ai" / "feedback" / "critique-001.md").write_bytes(feedback) + + # Simulate author self-test residue in the tracked tree; reset must recover it. + (repo / "tracked.txt").write_text("author self-test edit\n", encoding="utf-8") + (repo / "untracked.tmp").write_text("remove me\n", encoding="utf-8") + return repo, base, feedback + + +def _assert_pinned_clean_tree(repo: Path, base: str) -> None: + status = _git(repo, "status", "--porcelain") + non_ai_entries = [ + line + for line in status.splitlines() + if not re.match(r"^.{3}\.ai(?:/|$)", line) + ] + assert not non_ai_entries, status + assert _git(repo, "diff", "--quiet", "--") == "" + assert _git(repo, "rev-parse", "HEAD") == base + + +def test_author_reset_removes_stale_hypothesis_patch_and_preserves_feedback( + tmp_path: Path, +) -> None: + repo, base, feedback = _fixture(tmp_path) + patch = repo / ".ai" / "hypothesis.patch" + patch.write_text("diff --git a/tracked.txt b/tracked.txt\nstale patch\n", encoding="utf-8") + + result = _run(_author_reset_command(), repo) + + assert result.returncode == 0, result.stderr + assert result.stdout.endswith("reset_proven") + assert not patch.exists() + assert not patch.is_symlink() + _assert_pinned_clean_tree(repo, base) + assert (repo / ".ai" / "feedback" / "critique-001.md").read_bytes() == feedback + + +def test_author_reset_removes_dangling_hypothesis_patch_symlink(tmp_path: Path) -> None: + repo, base, feedback = _fixture(tmp_path) + patch = repo / ".ai" / "hypothesis.patch" + patch.symlink_to("missing-hypothesis.patch") + assert patch.is_symlink() and not patch.exists() + + result = _run(_author_reset_command(), repo) + + assert result.returncode == 0, result.stderr + assert result.stdout.endswith("reset_proven") + assert not patch.exists() + assert not patch.is_symlink() + _assert_pinned_clean_tree(repo, base) + assert (repo / ".ai" / "feedback" / "critique-001.md").read_bytes() == feedback + + +def test_author_reset_reports_unproven_when_patch_cleanup_cannot_remove_directory( + tmp_path: Path, +) -> None: + repo, base, feedback = _fixture(tmp_path) + patch = repo / ".ai" / "hypothesis.patch" + patch.mkdir() + (patch / "cannot-be-unlinked-by-rm-f").write_text("stale\n", encoding="utf-8") + + result = _run(_author_reset_command(), repo) + + assert result.returncode == 0, result.stderr + assert result.stdout.endswith("reset_unproven") + assert patch.is_dir() + assert "AUTHOR-RESET: RESET NOT PROVEN" in (repo / ".ai" / "gate.log").read_text( + encoding="utf-8" + ) + _assert_pinned_clean_tree(repo, base) + assert (repo / ".ai" / "feedback" / "critique-001.md").read_bytes() == feedback