Files
multi-agent-mux/tests/test_deploy_freshness.py
T

265 lines
12 KiB
Python

"""Deploy freshness — the deploy/ scripts must actually ship the latest assets.
``deploy/install.sh`` treats only ``.agents/skills/**`` as framework-owned. Every
other shipped asset (``.agents/hooks.json``, ``.agents/hooks/*.sh``,
``.agents/MULTI_AGENT_RULES*.md``, ``.agents/INSTALL.md``) takes the
``elif [ ! -e "$dest" ]`` branch, so it is copied once and never refreshed.
``deploy/remove.sh``'s manifest-less fallback list is likewise frozen at the
pre-loop skill set, so it strands those same assets plus the whole
``multi-agent-mux-loop`` skill.
"""
import json
import os
import shutil
import subprocess
import tempfile
import pytest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# Framework assets that live outside .agents/skills/ and must still be
# refreshed. Mapped as {installed path: path in the source tree} because the
# user manual is installed from deploy/, not from .agents/.
NON_SKILL_ASSETS = {
".agents/hooks.json": ".agents/hooks.json",
".agents/hooks/loop_delegation_guard.sh": ".agents/hooks/loop_delegation_guard.sh",
".agents/MULTI_AGENT_RULES.md": ".agents/MULTI_AGENT_RULES.md",
".agents/MULTI_AGENT_RULES.ko.md": ".agents/MULTI_AGENT_RULES.ko.md",
".agents/INSTALL.md": "deploy/INSTALL.md",
}
MARK = "UPSTREAM-RELEASE-MARKER"
@pytest.fixture
def src_and_target():
"""A pristine copy of the working tree plus an empty install target.
Runtime directories are excluded so the fixture stays fast and so the
source can never be confused with an already-installed workspace.
"""
tmp = tempfile.mkdtemp(prefix="mam_deploy_fresh_")
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
shutil.copytree(REPO_ROOT, src,
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
".mam_deploy", "__pycache__"),
symlinks=True)
os.makedirs(tgt)
try:
yield src, tgt
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _install(src, tgt, **extra):
env = dict(os.environ)
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
"MAM_SKIP_GITIGNORE": "1"})
env.update(extra)
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
env=env, capture_output=True, text=True)
def _bump(src, rel):
"""Simulate an upstream release that changed ``rel``."""
path = os.path.join(src, rel)
assert os.path.exists(path), "no such source asset: %s" % rel
if rel.endswith(".json"):
with open(path) as f:
data = json.load(f)
data["_release"] = MARK
with open(path, "w") as f:
json.dump(data, f, indent=2)
else:
with open(path, "a") as f:
f.write("\n# %s\n" % MARK)
# --------------------------------------------------------------------------
# D-1 — a refresh install must deliver upstream changes to EVERY framework
# asset, not only to those under .agents/skills/.
# --------------------------------------------------------------------------
def test_d1_refresh_updates_non_skill_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
for source_rel in NON_SKILL_ASSETS.values():
_bump(src, source_rel)
_bump(src, ".agents/skills/lib.sh") # control: this one is known to work
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(os.path.join(tgt, ".agents/skills/lib.sh")) as f:
assert MARK in f.read(), (
"control failed: even a skills/ file did not refresh, so this test "
"is not measuring what it claims")
stale = []
for rel in NON_SKILL_ASSETS:
with open(os.path.join(tgt, rel)) as f:
if MARK not in f.read():
stale.append(rel)
assert not stale, (
"install.sh refreshed .agents/skills/ but left these framework assets "
"at their originally-installed version: %s. The O-3 guard and the "
"MULTI_AGENT_RULES protocol therefore never reach an existing "
"workspace." % stale)
# --------------------------------------------------------------------------
# D-2 — remove.sh's manifest-less fallback must clear every asset the
# installers write, or the next install is blocked by the survivors
# (install.sh only copies a non-skill asset when it does not exist).
# --------------------------------------------------------------------------
def test_d2_manifestless_removal_strands_no_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
# An install_mam.sh deployment leaves no manifest; force that code path.
os.remove(os.path.join(tgt, ".mam", "install_manifest.txt"))
res = subprocess.run(["bash", os.path.join(tgt, ".mam_deploy", "remove.sh"),
"--force", tgt], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
survivors = []
for root, _, files in os.walk(os.path.join(tgt, ".agents")):
for f in files:
survivors.append(os.path.relpath(os.path.join(root, f), tgt))
assert not survivors, (
"the manifest-less fallback left framework assets behind: %s. Because "
"install.sh copies a non-skill asset only when it is absent, these "
"survivors permanently pin the workspace to the old version."
% sorted(survivors))
# --------------------------------------------------------------------------
# D-3 — install_mam.sh must record a manifest, so that remove.sh/update.sh
# take the exact-reversal path instead of the frozen fallback list.
# --------------------------------------------------------------------------
def test_d3_install_mam_records_a_manifest():
body = open(os.path.join(REPO_ROOT, "deploy", "install_mam.sh")).read()
assert "install_manifest.txt" in body, (
"deploy/install_mam.sh writes no .mam/install_manifest.txt, so every "
"workspace it deploys is uninstalled and updated through remove.sh's "
"frozen fallback list")
# --------------------------------------------------------------------------
# D-5 — the fetch-validation gate must cover the assets a broken fetch would
# plausibly drop, including the O-3 hook pair.
# --------------------------------------------------------------------------
def test_d5_asset_presence_gate_covers_the_hook_pair():
body = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
start = body.index("check_assets_present()")
gate = body[start:body.index("}", body.index("return 0", start))]
for rel in (".agents/hooks.json", ".agents/hooks/loop_delegation_guard.sh"):
assert rel in gate, (
"check_assets_present() does not require %s, so a fetch that "
"silently dropped the O-3 guard still passes validation and "
"installs a workspace whose guard is inert" % rel)
# --------------------------------------------------------------------------
# D-6 — asset_hashes.txt drives both the refresh 3-way merge and remove.sh's
# "preserve modified files" backup. Framework files outside skills/ get
# neither today.
# --------------------------------------------------------------------------
def test_d6_hash_db_covers_non_skill_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
with open(os.path.join(tgt, ".mam", "asset_hashes.txt")) as f:
db = f.read()
missing = [rel for rel in NON_SKILL_ASSETS if rel not in db]
assert not missing, (
"these framework assets are absent from .mam/asset_hashes.txt: %s. "
"Local edits to them are silently destroyed by remove.sh instead of "
"being backed up." % missing)
# --------------------------------------------------------------------------
# D-7 — every variable the installer writes into a fresh .mam.env must be
# documented in the committed template.
# --------------------------------------------------------------------------
def test_d7_env_template_documents_installer_applied_defaults():
installer = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
start_idx = installer.index("cat <<EOF > \"$MAM_ENV\"")
end_idx = installer.index("EOF", start_idx + 15)
block = installer[start_idx:end_idx]
written = [ln.split("=", 1)[0].strip() for ln in block.splitlines()
if "=" in ln and not ln.strip().startswith("#")]
template = open(os.path.join(REPO_ROOT, ".mam.env.example")).read()
undocumented = [v for v in written if v not in template]
assert not undocumented, (
"install.sh seeds %s into every new .mam.env, but .mam.env.example "
"never mentions them, so users cannot discover or correct them"
% undocumented)
# --------------------------------------------------------------------------
# D-8 — CI must lint the shipped hook and run the test suite that every
# recent feature commit claims as its evidence.
# --------------------------------------------------------------------------
def test_d8_ci_lints_the_hook_and_runs_the_tests():
ci = open(os.path.join(REPO_ROOT, "deploy", "gitea-ci.yml")).read()
assert ".agents/hooks/loop_delegation_guard.sh" in ci, (
"gitea-ci.yml shellchecks every other shipped bash entrypoint but not "
"the O-3 hook")
assert "pytest" in ci, (
"gitea-ci.yml runs no pytest job, so none of the 17 files under tests/ "
"ever gate a release")
# --------------------------------------------------------------------------
# D-9 — .agents/INSTALL.md is shipped from two different sources: the .agents/
# walk and the later `cp deploy/INSTALL.md`, whose `[ ! -e ]` guard can
# therefore never fire. One of the two must not exist, or the herdr
# migration's doc update silently loses to the tmux-era copy.
# --------------------------------------------------------------------------
def test_d9_installed_manual_is_not_the_stale_tmux_era_copy(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
with open(os.path.join(tgt, ".agents", "INSTALL.md")) as f:
shipped = f.read()
assert "tmux" not in shipped, (
"the installed .agents/INSTALL.md is the pre-herdr copy. install.sh "
"stages .agents/INSTALL.md from the repo first, so its later "
"`cp deploy/INSTALL.md` -- guarded by `[ ! -e ]` -- never runs and the "
"up-to-date manual is never delivered")
# --------------------------------------------------------------------------
# D-10 — a preserved customization must stay preserved across REPEATED
# refreshes. The hash DB was rebuilt from the working copy, so the
# second refresh saw target == db, classified the file as unmodified,
# and overwrote it with no notice.
# --------------------------------------------------------------------------
def test_d10_customization_survives_repeated_refresh(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
skill = os.path.join(tgt, ".agents", "skills", "lib.sh")
with open(skill, "a") as f:
f.write("\n# MY_LOCAL_TWEAK\n")
for n in (2, 3, 4):
if n == 4: # upstream also moves on
_bump(src, ".agents/skills/lib.sh")
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(skill) as f:
body = f.read()
assert "MY_LOCAL_TWEAK" in body, (
"the local customization was destroyed on refresh #%d. install.sh "
"rebuilds .mam/asset_hashes.txt from the working copy, so after "
"the first PRESERVE the modified hash becomes the canonical one "
"and the next refresh silently overwrites it." % n)
assert "Local modification detected" in res.stderr, (
"refresh #%d overwrote nothing but also reported nothing; the "
"user gets no signal that their edit is diverging" % n)