d198dd5fixed one helper; the same defect was in eleven more modules (reviews grok and codex R1/R2): their shells inherited the process environment, so each `source lib.sh` rewrote <WORKSPACE_ROOT>/.mam/shim/herdr - the checkout's own when the variable is unset or names this checkout (which pytest_configure keeps), another workspace's under --noconftest. - conftest: own_workspace_root, an opt-in fixture setting WORKSPACE_ROOT to <tmp_path>/.mam-test-state/workspace. Not autouse: runner.py and mqtt_common read WORKSPACE_ROOT in-process, and some tests unset it on purpose. mam_sandbox and explicit envs run later and still win. - pytestmark usefixtures("own_workspace_root") in test_version_consistency, test_b19_headless_reconcile_fixes, test_b8_send_keys_verification, test_issue6_fail_closed, test_loop_cli, test_issue5_phase3_loop, test_issue5_phase4_runtime, test_o3_scoped_guard, test_issue25_plan_reuse, test_orc_onboard (their shells inherit os.environ, many with no env at all). - test_orc_onboard (R2): SCRUBBED_ENV is taken at import and no longer carries WORKSPACE_ROOT; every call builds _scrubbed_env(root) with the root of the call (ws / cwd / mam_sandbox, else this test's). - test_workspace_scope (unittest) and test_issue21_worktree_prereqs._mutate pin the root they already create. Tests (test_issue17_workspace_root_isolation.py): W1 copies the checkout and runs one test of each of those modules there with WORKSPACE_ROOT naming the copy - the copy gets no .mam; W2 imports test_orc_onboard under a sentinel root, then calls it under another - the sentinel stays empty. Atd198dd5both fail (the copy gets .mam/shim/herdr; the sentinel gets it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
145 lines
5.7 KiB
Python
145 lines
5.7 KiB
Python
# ==============================================================================
|
|
# test_version_consistency.py — 3-Way Version Lockstep & Immutability Test Suite
|
|
# ==============================================================================
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
import yaml
|
|
import pytest
|
|
|
|
# Issue #17: shells started here inherit a WORKSPACE_ROOT of this test's own
|
|
# (lib.sh would otherwise rewrite the checkout's .mam/shim/herdr).
|
|
pytestmark = pytest.mark.usefixtures("own_workspace_root")
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
LIB_SH = REPO_ROOT / ".agents" / "skills" / "lib.sh"
|
|
VERSIONS_MD = REPO_ROOT / "VERSIONS.md"
|
|
SKILLS_DIR = REPO_ROOT / ".agents" / "skills"
|
|
EAGER_PY = SKILLS_DIR / "lib_py" / "loop" / "eager.py"
|
|
|
|
SKILL_NAMES = [
|
|
"multi-agent-mux-create",
|
|
"multi-agent-mux-stop",
|
|
"multi-agent-mux-resume",
|
|
"multi-agent-mux-status",
|
|
"multi-agent-mux-monitor",
|
|
"multi-agent-mux-delegate-job",
|
|
"multi-agent-mux-loop",
|
|
"multi-agent-mux-orc-onboard",
|
|
]
|
|
|
|
|
|
def _get_lib_sh_mam_version():
|
|
cmd = f'source "{LIB_SH}" && echo "$MAM_VERSION"'
|
|
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(REPO_ROOT))
|
|
assert res.returncode == 0, f"Failed to source lib.sh: {res.stderr}"
|
|
return res.stdout.strip()
|
|
|
|
|
|
def _get_versions_md_versions():
|
|
text = VERSIONS_MD.read_text(encoding="utf-8")
|
|
|
|
# 1. Current release framework version
|
|
m_curr = re.search(r"-\s*\*\*프레임워크 버전\*\*:\s*`v(\d+\.\d+\.\d+)`", text)
|
|
assert m_curr, "Failed to parse Current Release framework version in VERSIONS.md"
|
|
current_framework_ver = m_curr.group(1)
|
|
|
|
# 2. Skill matrix table versions
|
|
table_matches = re.findall(r"\|\s*\*\*`([^`]+)`\*\*\s*\|\s*`(\d+\.\d+\.\d+)`\s*\|", text)
|
|
matrix_dict = dict(table_matches)
|
|
|
|
return current_framework_ver, matrix_dict
|
|
|
|
|
|
def _get_eager_py_framework_version():
|
|
"""Read FRAMEWORK_VERSION statically — importing lib_py.loop.eager would
|
|
pull the whole framework onto sys.path just to read one constant."""
|
|
text = EAGER_PY.read_text(encoding="utf-8")
|
|
m = re.search(r'^FRAMEWORK_VERSION\s*=\s*"(\d+\.\d+\.\d+)"', text, re.M)
|
|
assert m, f"Failed to parse FRAMEWORK_VERSION in {EAGER_PY}"
|
|
return m.group(1)
|
|
|
|
|
|
def _get_skill_md_frontmatter_version(skill_name: str):
|
|
skill_file = SKILLS_DIR / skill_name / "SKILL.md"
|
|
assert skill_file.is_file(), f"Skill file not found: {skill_file}"
|
|
content = skill_file.read_text(encoding="utf-8")
|
|
|
|
# Strict frontmatter parsing
|
|
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
assert fm_match, f"No YAML frontmatter found in {skill_file}"
|
|
fm_data = yaml.safe_load(fm_match.group(1))
|
|
assert isinstance(fm_data, dict) and "version" in fm_data, f"No version in frontmatter of {skill_file}"
|
|
return str(fm_data["version"])
|
|
|
|
|
|
def test_three_way_version_lockstep():
|
|
"""T-4: lib.sh(MAM_VERSION) == VERSIONS.md(Current + Matrix 8 items) == 8x SKILL.md frontmatters."""
|
|
# 1. lib.sh runtime constant
|
|
lib_ver = _get_lib_sh_mam_version()
|
|
assert lib_ver != "", "MAM_VERSION in lib.sh is empty"
|
|
|
|
# 2. VERSIONS.md
|
|
vmd_curr, vmd_matrix = _get_versions_md_versions()
|
|
assert lib_ver == vmd_curr, f"lib.sh MAM_VERSION ({lib_ver}) != VERSIONS.md current ({vmd_curr})"
|
|
|
|
for skill in SKILL_NAMES:
|
|
assert skill in vmd_matrix, f"Skill {skill} missing from VERSIONS.md matrix"
|
|
assert vmd_matrix[skill] == lib_ver, f"VERSIONS.md matrix for {skill} ({vmd_matrix[skill]}) != lib.sh ({lib_ver})"
|
|
|
|
# 3. 8x SKILL.md files
|
|
for skill in SKILL_NAMES:
|
|
fm_ver = _get_skill_md_frontmatter_version(skill)
|
|
assert fm_ver == lib_ver, f"{skill}/SKILL.md version ({fm_ver}) != lib.sh ({lib_ver})"
|
|
|
|
# 4. Runtime constant in lib_py/loop/eager.py.
|
|
eager_ver = _get_eager_py_framework_version()
|
|
assert eager_ver == lib_ver, (
|
|
f"eager.py FRAMEWORK_VERSION ({eager_ver}) != lib.sh MAM_VERSION ({lib_ver})"
|
|
)
|
|
|
|
|
|
def test_runtime_version_gate_rejects_a_partially_bumped_tree():
|
|
"""A tree where only one of the two runtime constants was bumped must not run.
|
|
|
|
lib.sh exports MAM_VERSION into the environment and eager_load() compares it
|
|
against FRAMEWORK_VERSION, so a half-updated deployment fails closed at loop
|
|
startup rather than running two versions of the framework against each other.
|
|
This guards the mechanism; the values themselves are pinned by the lockstep
|
|
test above.
|
|
"""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("_eager_under_test", EAGER_PY)
|
|
eager = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(eager)
|
|
|
|
assert eager.FRAMEWORK_VERSION == _get_lib_sh_mam_version()
|
|
|
|
env_before = os.environ.get("MAM_VERSION")
|
|
os.environ["MAM_VERSION"] = "0.0.0-stale"
|
|
try:
|
|
try:
|
|
eager.verify_framework_version()
|
|
except RuntimeError as exc:
|
|
assert "MAM_VERSION mismatch" in str(exc)
|
|
else:
|
|
raise AssertionError("a stale MAM_VERSION was accepted by the runtime gate")
|
|
finally:
|
|
if env_before is None:
|
|
os.environ.pop("MAM_VERSION", None)
|
|
else:
|
|
os.environ["MAM_VERSION"] = env_before
|
|
|
|
|
|
def test_mam_version_is_not_env_overridable():
|
|
"""T-0 (Constraint 1): MAM_VERSION cannot be spoofed by caller environment."""
|
|
cmd = f'source "{LIB_SH}" && echo "$MAM_VERSION"'
|
|
env = os.environ.copy()
|
|
env["MAM_VERSION"] = "9.9.9"
|
|
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env=env, cwd=str(REPO_ROOT))
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() != "9.9.9", "MAM_VERSION was improperly overwritten by caller environment"
|
|
assert res.stdout.strip() == _get_lib_sh_mam_version()
|