The update is a skill (decision 4); deploy/update.sh and the installed .mam_deploy/update.sh are gone. - Phase 1 (installed code, update.sh): publish the update marker under the exclusive admission lock; refuse before any change while a running row, a live herdr agent in the workspace on any running server, an open job (unreadable JSON included, dry_run not), a live loop marker or a monitor.lock held by a monitor the skills did not start (U3) is there - the calling agent (server + pane, its row and one running job) excepted (lib_py/update.py precheck, decisions 1 and 7). Stop the monitor (TERM, then KILL and restart it if it does not end; decision 2), handle a leftover backup by its state (U4), back up .agents, .mam_deploy, root files, .venv, the installer's .mam files and the registry (SQLite backup API under the registry lock) into .mam/update-backup/, then exec a copy of install_phase.sh outside the tree (decision 5). - The copy (no lib.sh/lib_py, bash 3.2) runs the installer in place; on failure it restores the backup and restarts the monitor from the restored tree; on success it runs phase 2, post_update.sh of the new tree, which converts the registry with lib_py.registry_format and restarts the monitor if it ran. The backup and the marker are removed at the end. - install.sh updates in place (decisions 3, 4, 9): install_state keys are set, one line each; no updater is installed and an old one (.mam_deploy/update.sh, or a root update.sh the manifest lists) is removed; framework files the new version no longer ships are deleted when unmodified, kept and listed when modified, and dropped from base/, the manifest and asset_hashes.txt; a local source's .mam is not staged. remove.sh knows the new skill; CI shellchecks its scripts; docs point at the skill. - test_issue8_update_state and test_deploy_layout td6/td27 run the skill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Issue #72 — the installer side of the multi-agent-mux-update skill.
|
|
|
|
The update skill runs the new install.sh in place over an existing install
|
|
(no remove.sh, .mam kept). These pin what that in-place run must do:
|
|
install_state keeps one line per key, no updater is shipped any more and an
|
|
old one is cleaned up, and framework files the new version no longer ships
|
|
are deleted unless the user modified them.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
# Extra framework-owned files only the "old" source ships.
|
|
RETIRED_SKILL = ".agents/skills/multi-agent-mux-retired72"
|
|
RETIRED_UNMODIFIED = [RETIRED_SKILL + "/SKILL.md", RETIRED_SKILL + "/scripts/old.sh"]
|
|
RETIRED_SHARED_DIR = ".agents/skills/multi-agent-mux-retired72b"
|
|
RETIRED_SHARED = RETIRED_SHARED_DIR + "/SKILL.md"
|
|
RETIRED_MODIFIED = ".agents/hooks/retired72_hook.sh"
|
|
|
|
|
|
def _copy_tree(dest):
|
|
shutil.copytree(REPO_ROOT, dest,
|
|
ignore=shutil.ignore_patterns(".git", ".venv", ".mam", ".mam_deploy",
|
|
"__pycache__", ".pytest_cache"),
|
|
symlinks=True)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sources():
|
|
"""(new, old): the tree as is, and the same tree plus files it no longer ships."""
|
|
tmp = tempfile.mkdtemp(prefix="mam_issue72_src_")
|
|
new, old = os.path.join(tmp, "new"), os.path.join(tmp, "old")
|
|
_copy_tree(new)
|
|
_copy_tree(old)
|
|
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED, RETIRED_MODIFIED]:
|
|
path = os.path.join(old, rel)
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "w") as f:
|
|
f.write("# shipped by the old version only: %s\n" % rel)
|
|
try:
|
|
yield new, old
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def ws():
|
|
tmp = tempfile.mkdtemp(prefix="mam_issue72_ws_")
|
|
try:
|
|
yield os.path.join(tmp, "workspace")
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def _install(src, ws):
|
|
env = dict(os.environ)
|
|
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1"})
|
|
res = subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), ws],
|
|
env=env, capture_output=True, text=True, timeout=600)
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
return res
|
|
|
|
|
|
def _remove(ws):
|
|
res = subprocess.run(["bash", os.path.join(ws, ".mam_deploy", "remove.sh"), "--force"],
|
|
cwd=ws, capture_output=True, text=True, timeout=600)
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
return res
|
|
|
|
|
|
def _read(ws, rel):
|
|
with open(os.path.join(ws, rel)) as f:
|
|
return f.read()
|
|
|
|
|
|
def _manifest(ws):
|
|
return _read(ws, ".mam/install_manifest.txt").splitlines()
|
|
|
|
|
|
def _hashed_paths(ws):
|
|
return [line.split(None, 1)[1] for line in _read(ws, ".mam/asset_hashes.txt").splitlines()
|
|
if line.strip()]
|
|
|
|
|
|
def test_in_place_reinstall_keeps_one_line_per_install_state_key(sources, ws):
|
|
new, _ = sources
|
|
_install(new, ws)
|
|
with open(os.path.join(ws, ".mam", "install_state"), "w") as f:
|
|
f.write("first=a\ngitignore_created=1\nlast=b\n")
|
|
|
|
_install(new, ws)
|
|
|
|
assert _read(ws, ".mam/install_state").splitlines() == [
|
|
"first=a", "gitignore_created=1", "last=b"]
|
|
# remove.sh must read the key as 1: the .gitignore it created goes away.
|
|
_remove(ws)
|
|
assert not os.path.exists(os.path.join(ws, ".gitignore"))
|
|
|
|
|
|
def test_fresh_install_ships_no_updater(sources, ws):
|
|
new, _ = sources
|
|
res = _install(new, ws)
|
|
assert not os.path.exists(os.path.join(ws, ".mam_deploy", "update.sh"))
|
|
manifest = _manifest(ws)
|
|
assert ".mam_deploy/update.sh" not in manifest and "update.sh" not in manifest
|
|
assert "bash .mam_deploy/update.sh" not in res.stdout
|
|
assert "multi-agent-mux-update/scripts/update.sh" in res.stdout
|
|
|
|
|
|
def test_in_place_reinstall_deletes_an_old_mam_deploy_updater(sources, ws):
|
|
new, _ = sources
|
|
_install(new, ws)
|
|
with open(os.path.join(ws, ".mam_deploy", "update.sh"), "w") as f:
|
|
f.write("#!/usr/bin/env bash\n# old updater\n")
|
|
with open(os.path.join(ws, ".mam", "install_manifest.txt"), "a") as f:
|
|
f.write(".mam_deploy/update.sh\n")
|
|
|
|
_install(new, ws)
|
|
|
|
assert not os.path.exists(os.path.join(ws, ".mam_deploy", "update.sh"))
|
|
assert ".mam_deploy/update.sh" not in _manifest(ws)
|
|
|
|
|
|
def test_legacy_root_updater_goes_only_when_the_manifest_lists_it(sources, ws):
|
|
new, _ = sources
|
|
_install(new, ws)
|
|
root_updater = os.path.join(ws, "update.sh")
|
|
|
|
# Listed: the installer put it there (very old layout).
|
|
with open(root_updater, "w") as f:
|
|
f.write("# legacy updater\n")
|
|
with open(os.path.join(ws, ".mam", "install_manifest.txt"), "a") as f:
|
|
f.write("update.sh\n")
|
|
_install(new, ws)
|
|
assert not os.path.exists(root_updater)
|
|
assert not os.path.exists(os.path.join(ws, ".mam_deploy", "update.sh"))
|
|
manifest = _manifest(ws)
|
|
assert "update.sh" not in manifest and ".mam_deploy/update.sh" not in manifest
|
|
|
|
# Not listed: the user's own file stays untouched.
|
|
with open(root_updater, "w") as f:
|
|
f.write("# user's own script\n")
|
|
_install(new, ws)
|
|
assert _read(ws, "update.sh") == "# user's own script\n"
|
|
|
|
|
|
def test_files_the_new_version_no_longer_ships(sources, ws):
|
|
new, old = sources
|
|
_install(old, ws)
|
|
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED, RETIRED_MODIFIED]:
|
|
assert rel in _manifest(ws) and rel in _hashed_paths(ws), rel
|
|
# The user edits one retired file and adds their own file next to another.
|
|
with open(os.path.join(ws, RETIRED_MODIFIED), "a") as f:
|
|
f.write("# my change\n")
|
|
with open(os.path.join(ws, RETIRED_SHARED_DIR, "notes.txt"), "w") as f:
|
|
f.write("mine\n")
|
|
# A base/ copy of a retired file must go with it.
|
|
base_copy = os.path.join(ws, ".mam", "base", RETIRED_UNMODIFIED[0])
|
|
os.makedirs(os.path.dirname(base_copy))
|
|
shutil.copy(os.path.join(ws, RETIRED_UNMODIFIED[0]), base_copy)
|
|
|
|
res = _install(new, ws)
|
|
|
|
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED]:
|
|
assert not os.path.exists(os.path.join(ws, rel)), rel
|
|
assert not os.path.exists(os.path.join(ws, RETIRED_SKILL)), "emptied directory left behind"
|
|
assert _read(ws, RETIRED_SHARED_DIR + "/notes.txt") == "mine\n"
|
|
assert "# my change" in _read(ws, RETIRED_MODIFIED)
|
|
assert "Local modification detected" in res.stderr and RETIRED_MODIFIED in res.stderr
|
|
for rel in RETIRED_UNMODIFIED:
|
|
assert rel not in res.stderr, rel
|
|
assert not os.path.exists(base_copy)
|
|
manifest, hashed = _manifest(ws), _hashed_paths(ws)
|
|
for rel in RETIRED_UNMODIFIED + [RETIRED_SHARED, RETIRED_MODIFIED]:
|
|
assert rel not in manifest and rel not in hashed, rel
|
|
# Still-shipped framework files are untouched by the cleanup.
|
|
assert ".agents/skills/lib.sh" in manifest and ".agents/skills/lib.sh" in hashed
|
|
|
|
# The kept file is the user's now: uninstalling leaves it in place.
|
|
_remove(ws)
|
|
assert "# my change" in _read(ws, RETIRED_MODIFIED)
|
|
|
|
|
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0,
|
|
reason="root reads a mode-000 file")
|
|
def test_local_source_update_backup_is_not_staged(sources, ws):
|
|
new, _ = sources
|
|
backup = os.path.join(new, ".mam", "update-backup")
|
|
os.makedirs(backup)
|
|
unreadable = os.path.join(backup, "unreadable")
|
|
with open(unreadable, "w") as f:
|
|
f.write("x")
|
|
os.chmod(unreadable, 0)
|
|
try:
|
|
_install(new, ws)
|
|
finally:
|
|
os.chmod(unreadable, 0o600)
|
|
shutil.rmtree(os.path.join(new, ".mam"))
|