From ec378e82c969f9ced6d9c11d35ff49a2edd5363c Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:05:00 +0000 Subject: [PATCH 01/16] fix(attach): hints print the row's attach_command (Issue #72, comments 926/930, U1) create's closing hint, the delegate-job wrapper's dispatch hint and stop's stuck-TUI message printed the pre-#71 forms (`herdr session attach `, `HERDR_SESSION_NAME=... herdr agent attach `), which miss an agent whose name is over 32 characters or has upper case. They now print the session row's attach_command, or lib_py.agents.sanitize.attach_command(server, name) when there is no row (lib.sh session_attach_command). deploy/INSTALL.md and install_mam.sh show the `herdr --session agent attach ` form. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 16 ++ .../scripts/create_session.sh | 2 +- .../multi-agent-mux-delegate-job | 2 +- .../scripts/stop_session.sh | 2 +- deploy/INSTALL.md | 2 +- deploy/install_mam.sh | 2 +- tests/test_issue72_attach_hints.py | 184 ++++++++++++++++++ 7 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue72_attach_hints.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 7ea7a63..e98976e 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -1671,6 +1671,22 @@ print(resolved) " } +# session_attach_command [server] +# +# Issue #72: the attach hint a script prints - the session row's +# attach_command, else the one lib_py.agents.sanitize.attach_command builds for +# (default: `default`). +session_attach_command() { + MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$1" ATTACH_SERVER="${2:-default}" python3 -c " +import os, json +from lib_py.agents.sanitize import attach_command +name = os.environ['SESSION_NAME'] +d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}') +row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {}) +print(row.get('attach_command') or attach_command(os.environ['ATTACH_SERVER'], name)) +" +} + # resolve_herdr_session [workspace] # # returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row, diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 6ccab1c..18cfa3b 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -767,6 +767,6 @@ fi trap - EXIT echo "agent-sessions.yaml updated" echo -echo "Attach: herdr session attach $SESSION_NAME" +echo "Attach: $(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")" echo "Delete: use multi-agent-mux-stop skill" echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)" diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index b0af734..e77146a 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -1018,7 +1018,7 @@ inject_agent() { return 1 fi - echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)" + echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $(session_attach_command "$sess" "${HERDR_SESSION_NAME:-default}"))" trap - EXIT return 0 } diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 3aaf352..50067a5 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -332,7 +332,7 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then echo "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2 echo " Refusing registry removal — records preserved (no state was modified)." >&2 - echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2 + echo " Diagnose the stuck TUI ($(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")), then re-run" >&2 echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2 delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" exit 4 diff --git a/deploy/INSTALL.md b/deploy/INSTALL.md index b2b6333..c9a4c65 100644 --- a/deploy/INSTALL.md +++ b/deploy/INSTALL.md @@ -60,7 +60,7 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \ ### 2) 세션 접속 (Attach) 백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다. ```bash -$ herdr session attach my-project-dev-claude +$ herdr --session multi-agent-mux agent attach my-project-dev-claude ``` * **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다. diff --git a/deploy/install_mam.sh b/deploy/install_mam.sh index fa55e3f..03d85a8 100755 --- a/deploy/install_mam.sh +++ b/deploy/install_mam.sh @@ -327,7 +327,7 @@ cat < + $ herdr --session multi-agent-mux agent attach 3. Gracefully stop the session: $ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\ diff --git a/tests/test_issue72_attach_hints.py b/tests/test_issue72_attach_hints.py new file mode 100644 index 0000000..21c2932 --- /dev/null +++ b/tests/test_issue72_attach_hints.py @@ -0,0 +1,184 @@ +"""Issue #72: the attach hints create, stop and the delegate-job wrapper print +are the session row's attach_command (Issue #71); with no row, the one +lib_py.agents.sanitize.attach_command builds for the server the script used. +""" +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import yaml + +REPO = Path(__file__).resolve().parent.parent +SKILLS = REPO / ".agents" / "skills" +sys.path.insert(0, str(SKILLS)) + +from lib_py.agents.sanitize import attach_command, sanitize_herdr_agent_name # noqa: E402 + +LONG = "lab-issue72-a-very-long-branch-name-creator-claude" # > 32 characters +UPPER = "Lab-Upper72-creator-claude" + + +def _set_mock(mock_herdr, **kw): + state = json.loads(Path(mock_herdr).read_text()) + state.update(kw) + Path(mock_herdr).write_text(json.dumps(state)) + + +def _rows(sandbox): + return yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"] + + +def _seed(sandbox, rows): + lib = sandbox / ".agents" / "skills" / "lib.sh" + mutation = "d['herdr_sessions'] = " + repr(rows) + "\n" + res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)], + input=mutation, capture_output=True, text=True) + assert res.returncode == 0, res.stderr + + +def _row(name, server, cwd, pid=1): + return {"name": name, "status": "running", "role": "creator", "agent": "claude", + "herdr_session": server, "herdr_server": server, "attach_command": attach_command(server, name), + "pane": {"index": 0, "pid": pid, "cmd": "claude", "cwd": str(cwd)}} + + +def _seeded_attach(sandbox, name, server): + (row,) = [r for r in _rows(sandbox) if r["name"] == name] + assert row["attach_command"] == attach_command(server, name) + return row["attach_command"] + + +def _agent(cwd, pane, pid=9999): + return {"agent": "claude", "status": "running", "agent_status": "idle", "cwd": str(cwd), "workspace_id": "w1", + "pid": pid, "pane_id": pane, "command": "claude", "buffer": "Ready"} + + +# --- create --------------------------------------------------------------------- + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_create_prints_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name): + assert sanitize_herdr_agent_name(name) != name, "the herdr name differs from the session name" + _set_mock(mock_herdr, strict_agent_names=True) + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + res = subprocess.run(["bash", str(script), "--workspace", str(mam_sandbox), "--agent", "claude", + "--role", "creator", "--session", name, "--herdr-session", "mam-iso72", "--no-onboard"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300, + env=dict(os.environ, MAM_NO_SUBSCRIBER_DAEMON="1")) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = [r for r in _rows(mam_sandbox) if r["name"] == name] + hints = [l for l in res.stdout.splitlines() if l.startswith("Attach:")] + assert hints == ["Attach: " + row["attach_command"]], res.stdout + assert row["attach_command"] == attach_command("mam-iso72", name) + + +# --- stop: the stuck-TUI message ------------------------------------------------ + +def _undying_herdr(mam_sandbox): + """The mock herdr behind a wrapper that ignores every way to close a pane, + so the session survives stop's kill chain.""" + bin_dir = mam_sandbox / "bin" + (bin_dir / "herdr").rename(bin_dir / "herdr-mock72") + (bin_dir / "herdr").write_text( + '#!/usr/bin/env bash\nfor a in "$@"; do case "$a" in kill-session|close) exit 0 ;; esac; done\n' + 'exec "%s" "$@"\n' % (bin_dir / "herdr-mock72")) + (bin_dir / "herdr").chmod(0o755) + + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_stop_stuck_tui_message_shows_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name): + # The SIGKILL fallback kills the pane pid: make it a child of this test. + pane = subprocess.Popen(["sleep", "300"]) + try: + # The row's server is not the one passed on the command line: the row wins. + _seed(mam_sandbox, [_row(name, "mam-row72", mam_sandbox, pid=pane.pid)]) + want = _seeded_attach(mam_sandbox, name, "mam-row72") + _set_mock(mock_herdr, strict_agent_names=True, + agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72", pid=pane.pid)}) + _undying_herdr(mam_sandbox) + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + res = subprocess.run(["bash", str(script), "--session", name, "--agent", "claude", + "--herdr-session", "mam-arg72", "--purge-conversation", "--yes"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300, + env=dict(os.environ, SKS_IDLE_TIMEOUT_MS="500")) + finally: + pane.kill() + pane.wait() + assert res.returncode == 4, res.stdout + res.stderr + hints = [l for l in res.stderr.splitlines() if "Diagnose the stuck TUI" in l] + assert hints == [" Diagnose the stuck TUI (%s), then re-run" % want], res.stderr + + +# --- delegate-job wrapper --------------------------------------------------------- + +def _delegate(mam_sandbox, mock_herdr, name): + """Submit a job to herdr:, acknowledge it as the agent would, and + return (stdout, the herdr server the wrapper prompted).""" + reg_dir = mam_sandbox / ".mam" / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + wrapper = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + publish = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py" + env = dict(os.environ, DELEGATE_JOB_PYTHON=sys.executable, MQTT_HOST="127.0.0.1", MQTT_PORT="65534", + SKS_IDLE_TIMEOUT_MS="500", MAM_INJECT_ACK_GRACE_S="20") + proc = subprocess.Popen(["bash", str(wrapper), "submit", "--agent", "claude-code", + "--agent-session", "herdr:" + name, "--prompt", "issue 72 attach hint", + "--registry-dir", str(reg_dir), "--workdir", str(mam_sandbox), + "--timeout", "6", "--idle-timeout", "2"], + cwd=str(mam_sandbox), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + tok, jid = "", "" + deadline = time.time() + 60 + while time.time() < deadline and not tok: + for f in reg_dir.glob("*.json"): + try: + tok, jid = json.loads(f.read_text()).get("dispatch_token") or "", f.stem + except ValueError: + pass + time.sleep(0.2) + assert tok, "the job was not dispatched" + ack = subprocess.run([sys.executable, str(publish), "--registry-dir", str(reg_dir), "--job", jid, + "--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=60) + # publish_event records `started` locally before its (here unreachable) broker publish + assert '"started"' in (reg_dir / (jid + ".events.log")).read_text(), ack.stderr + stdout, stderr = proc.communicate(timeout=180) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + calls = json.loads(Path(mock_herdr).read_text())["calls"] + prompts = [c for c in calls if "agent" in c[:3] and "prompt" in c[:4]] + assert prompts, (calls, stdout, stderr) + servers = {c[1] if c[0] == "--session" else "default" for c in prompts} + assert len(servers) == 1, prompts + return stdout + stderr, servers.pop() + + +def _delegate_hint(out): + hints = [m.group(1) for m in (re.search(r"\(연결하려면: (.*)\)$", l) for l in out.splitlines()) if m] + assert len(hints) == 1, out + return hints[0] + + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_delegate_prints_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name): + _seed(mam_sandbox, [_row(name, "mam-row72", mam_sandbox)]) + want = _seeded_attach(mam_sandbox, name, "mam-row72") + _set_mock(mock_herdr, strict_agent_names=True, + agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72")}) + out, server = _delegate(mam_sandbox, mock_herdr, name) + assert server == "mam-row72" + assert _delegate_hint(out) == want + + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_delegate_without_a_row_builds_it_for_the_server_it_used(mam_sandbox, mock_herdr, mock_agents, name): + assert not any(r["name"] == name for r in _rows(mam_sandbox)) + _set_mock(mock_herdr, strict_agent_names=True, + agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72")}) + out, server = _delegate(mam_sandbox, mock_herdr, name) + assert _delegate_hint(out) == attach_command(server, name) -- 2.54.0 From 179473e5d5447219e22c669ed24d34e61bb1f20c Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:06:32 +0000 Subject: [PATCH 02/16] feat(resume): resume ensures the monitor daemon (Issue #72, decision 2) Only create started the per-workspace monitor, and only on its job path, so the stop -> update -> resume flow the update skill requires left no monitor. resume now calls start_watchdog with the control root as workdir (as create does since #21 A8, also for a worktree session) after the row is written, on both the already-running and the spawn path. MAM_NO_SUBSCRIBER_DAEMON=1 starts none; a failure warns and does not fail the resume. Co-Authored-By: Claude Opus 5.5 --- .../scripts/resume_session.sh | 14 ++ tests/data/skill_code_blocks.json | 10 +- tests/test_issue72_resume_monitor.py | 183 ++++++++++++++++++ 3 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue72_resume_monitor.py diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 400e827..cd76362 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -154,6 +154,18 @@ if [ -z "$UUID" ]; then esac fi +# Issue #72: a resumed session gets the monitor daemon create starts - the +# control root's (Issue #21 A8), also for a worktree --workspace. Called after +# the row is written; MAM_NO_SUBSCRIBER_DAEMON=1 starts none. Never fails the resume. +_ensure_monitor_daemon() { + local wd_pid + if wd_pid=$(start_watchdog "resume" "$WORKSPACE_ROOT"); then + echo "watchdog PID: $wd_pid" + else + echo "WARNING: could not start the monitor daemon of '$WORKSPACE_ROOT'; the session itself was resumed" >&2 + fi +} + # 2. If herdr is alive, print warning or attach. if [ "$HERDR_ALIVE" = "1" ]; then if [ "${DRY_RUN:-0}" = "1" ]; then @@ -166,6 +178,7 @@ if [ "$HERDR_ALIVE" = "1" ]; then --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --already-running \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} + _ensure_monitor_daemon exit 0 fi @@ -274,5 +287,6 @@ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_r --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${DROP_FLAG:+$DROP_FLAG} \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} +_ensure_monitor_daemon echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index 90b67e7..c04ea77 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -476,11 +476,11 @@ "evidence": [ "multi-agent-mux-resume/scripts/resume_session.sh:111-130", "multi-agent-mux-resume/scripts/resume_session.sh:133-144", - "multi-agent-mux-resume/scripts/resume_session.sh:147-158", - "multi-agent-mux-resume/scripts/resume_session.sh:172-197", - "multi-agent-mux-resume/scripts/resume_session.sh:245", - "multi-agent-mux-resume/scripts/resume_session.sh:249", - "multi-agent-mux-resume/scripts/resume_session.sh:255-258", + "multi-agent-mux-resume/scripts/resume_session.sh:147-170", + "multi-agent-mux-resume/scripts/resume_session.sh:185-210", + "multi-agent-mux-resume/scripts/resume_session.sh:258", + "multi-agent-mux-resume/scripts/resume_session.sh:262", + "multi-agent-mux-resume/scripts/resume_session.sh:268-271", "lib_py/agents/adapters/claude.py:109" ], "mismatch": [ diff --git a/tests/test_issue72_resume_monitor.py b/tests/test_issue72_resume_monitor.py new file mode 100644 index 0000000..a7f45ab --- /dev/null +++ b/tests/test_issue72_resume_monitor.py @@ -0,0 +1,183 @@ +"""Issue #72 (decision 2): resume ensures the monitor daemon, as create does. + +Only create_session.sh started the per-workspace subscriber daemon, so a +session brought back by resume_session.sh had no monitor unless one already +ran. Resume now calls the same `start_watchdog` with the control root as the +workdir (Issue #21 A8: never a worktree --workspace's copy), on the spawn path +and the already-running path, after the row is written. + +What is real: the shipped create/stop/resume scripts and the real +start_watchdog -> lib_py ensure-daemon, against the mock herdr in a sandbox. +What is not: the sandbox's monitor script is a stub that records how it was +started and sleeps, so the daemon's `pgrep -f "bash --subscribe"` +check sees it but nothing reconciles. Every stub is killed in teardown. +""" + +import os +import signal +import subprocess +import time + +import pytest + +CREATE = "multi-agent-mux-create/scripts/create_session.sh" +STOP = "multi-agent-mux-stop/scripts/stop_session.sh" +RESUME = "multi-agent-mux-resume/scripts/resume_session.sh" +MONITOR_REL = os.path.join(".agents", "skills", "multi-agent-mux-monitor", "scripts", "reconcile.sh") + + +def _script(root, rel): + return str(root / ".agents" / "skills" / rel) + + +def _install_stub_monitor(base, log): + """Replace 's monitor with a stub that writes `pid|$0|cwd|args`.""" + path = base / MONITOR_REL + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('#!/bin/bash\nprintf \'%%s|%%s|%%s|%%s\\n\' "$$" "$0" "$PWD" "$*" >> "%s"\n' + 'sleep 60 &\nwait $!\n' % log) + path.chmod(0o755) + return str(path) + + +def _starts(log): + return [line.split("|") for line in log.read_text().splitlines()] if log.exists() else [] + + +def _wait_starts(log, n, timeout=10.0): + deadline = time.time() + timeout + while time.time() < deadline and len(_starts(log)) < n: + time.sleep(0.1) + return _starts(log) + + +@pytest.fixture +def stub(mam_sandbox): + """The control root's stub monitor; kills every stub process it recorded + (the mam_sandbox reaper also sweeps anything under the sandbox).""" + logs = [] + + def install(base, name): + log = mam_sandbox / ("monitor-starts-%s.log" % name) + logs.append(log) + return _install_stub_monitor(base, log), log + + yield install + for log in logs: + for row in _starts(log): + pid = int(row[0]) + for target in subprocess.run(["pgrep", "-P", str(pid)], capture_output=True, + text=True).stdout.split() + [str(pid)]: + try: + os.kill(int(target), signal.SIGKILL) + except OSError: + pass + + +def _create(root, ws, name, stop): + subprocess.run(["bash", _script(root, CREATE), "--workspace", ws, "--agent", "claude", + "--role", "creator", "--session", name, "--no-onboard"], + check=True, cwd=str(root), capture_output=True, text=True) + if stop: + subprocess.run(["bash", _script(root, STOP), "--session", name, "--agent", "claude"], + check=True, cwd=str(root), capture_output=True, text=True) + + +def _resume(root, ws, name): + return subprocess.run(["bash", _script(root, RESUME), "--workspace", ws, "--agent", "claude", + "--session", name], cwd=str(root), capture_output=True, text=True, timeout=120) + + +def _assert_root_monitor_started(root, monitor, log, res): + starts = _wait_starts(log, 1) + assert len(starts) == 1, starts + pid, script, cwd, args = starts[0] + assert os.path.realpath(script) == os.path.realpath(monitor) + assert os.path.realpath(cwd) == os.path.realpath(str(root)) + assert args == "--subscribe --idle-timeout 0" + assert "watchdog PID: %s" % pid in res.stdout, res.stdout + + +def test_spawn_path_ensures_the_control_root_monitor(mam_sandbox, mock_herdr, mock_agents, stub, monkeypatch): + root = mam_sandbox + _create(root, str(root), "issue72-spawn", stop=True) + monitor, log = stub(root, "root") + monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) + res = _resume(root, str(root), "issue72-spawn") + assert res.returncode == 0, res.stdout + res.stderr + assert "Successfully resumed" in res.stdout + _assert_root_monitor_started(root, monitor, log, res) + + +def test_already_running_path_ensures_the_monitor_once(mam_sandbox, mock_herdr, mock_agents, stub, monkeypatch): + root = mam_sandbox + _create(root, str(root), "issue72-alive", stop=False) + monitor, log = stub(root, "root") + monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) + res = _resume(root, str(root), "issue72-alive") + assert res.returncode == 0, res.stdout + res.stderr + assert "already running" in res.stdout + _assert_root_monitor_started(root, monitor, log, res) + # A second resume finds the running daemon and starts no other. + pid = _starts(log)[0][0] + again = _resume(root, str(root), "issue72-alive") + assert again.returncode == 0, again.stdout + again.stderr + assert "watchdog PID: %s" % pid in again.stdout + time.sleep(1) + assert len(_starts(log)) == 1 + + +@pytest.mark.parametrize("stop", [True, False], ids=["spawn", "already-running"]) +def test_worktree_session_uses_the_control_root_monitor(mam_sandbox, mock_herdr, mock_agents, stub, monkeypatch, stop): + """A session whose pane cwd (--workspace) is not the control root still + gets the control root's monitor, never the copy in its own tree.""" + root = mam_sandbox + wt = root / "wt-issue72" + wt.mkdir() + name = "issue72-wt-" + ("spawn" if stop else "alive") + _create(root, str(wt), name, stop=stop) + monitor, log = stub(root, "root") + _, wt_log = stub(wt, "worktree") + monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) + res = _resume(root, str(wt), name) + assert res.returncode == 0, res.stdout + res.stderr + _assert_root_monitor_started(root, monitor, log, res) + assert _starts(wt_log) == [] + + +def test_opt_out_starts_nothing_and_reports_a_running_daemon(mam_sandbox, mock_herdr, mock_agents, stub): + """MAM_NO_SUBSCRIBER_DAEMON=1 (the sandbox default): resume starts no + daemon; one that already runs is still found and reported.""" + root = mam_sandbox + assert os.environ.get("MAM_NO_SUBSCRIBER_DAEMON") == "1" + _create(root, str(root), "issue72-optout", stop=True) + monitor, log = stub(root, "root") + res = _resume(root, str(root), "issue72-optout") # spawn path + assert res.returncode == 0, res.stdout + res.stderr + time.sleep(1) + assert _starts(log) == [] + + pre = subprocess.Popen(["bash", monitor, "--subscribe", "--idle-timeout", "0"], cwd=str(root), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + try: + assert len(_wait_starts(log, 1)) == 1 + res = _resume(root, str(root), "issue72-optout") # already-running path + assert res.returncode == 0, res.stdout + res.stderr + assert "watchdog PID: %d" % pre.pid in res.stdout, res.stdout + time.sleep(1) + assert len(_starts(log)) == 1 + finally: + pre.kill() + pre.wait() + + +def test_a_missing_monitor_warns_and_the_resume_still_succeeds(mam_sandbox, mock_herdr, mock_agents, monkeypatch): + root = mam_sandbox + _create(root, str(root), "issue72-nomon", stop=True) + os.remove(str(root / MONITOR_REL)) + monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) + res = _resume(root, str(root), "issue72-nomon") + assert res.returncode == 0, res.stdout + res.stderr + assert "Successfully resumed" in res.stdout + assert "WARNING: could not start the monitor daemon" in res.stderr -- 2.54.0 From 6359d65999dd14bfb581bcf44e6ad1ad4c83c127 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:06:42 +0000 Subject: [PATCH 03/16] feat: update admission gate - entries refuse while an update runs (Issue #72, decision 8, U2) The update skill publishes .mam/update-in-progress (pid + lstart, the loop_lock.sh rules) while it holds .mam/update-admission.lock exclusively. create, resume, run_loop.sh and delegate submit (decision 8) and stop and orc-onboard (U2: a rollback would restore the registry without their write) take that lock shared (lib.sh mam_update_admit, fd 7), refuse while the marker is live or unknown, and hold the lock until their record is written. A stale marker refuses nothing. lib_py.pane_lock acquire gains --shared and --wait-env (MAM_UPDATE_ADMISSION_WAIT_S, default 120); create's wrapper path closes fd 7 for the long-lived child. The exit-1 rows of create/resume/stop name the refusal; skill_code_blocks.json pins follow the shifted lines. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 52 +++++ .agents/skills/lib_py/pane_lock.py | 15 +- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../scripts/create_session.sh | 6 +- .../multi-agent-mux-delegate-job | 5 + .../multi-agent-mux-loop/scripts/run_loop.sh | 4 + .../scripts/orc_onboard.sh | 6 + .../skills/multi-agent-mux-resume/SKILL.md | 4 +- .../scripts/resume_session.sh | 8 + .agents/skills/multi-agent-mux-stop/SKILL.md | 2 +- .../scripts/stop_session.sh | 6 + tests/data/skill_code_blocks.json | 30 +-- tests/test_issue72_admission.py | 196 ++++++++++++++++++ 13 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 tests/test_issue72_admission.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index e98976e..4951ec4 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3247,3 +3247,55 @@ mam_control_plane_prefix() { [ -n "${1:-}" ] || return 0 PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1" } + +# --------------------------------------------------------------------------- +# Issue #72 (decision 8): the update admission gate. +# +# The update skill publishes .mam/update-in-progress (pid + lstart, loop_lock.sh +# rules) while it holds .mam/update-admission.lock exclusively, then runs its +# pre-check. Every entry that starts something the pre-check must see - create, +# resume, run_loop.sh, delegate submit - or writes the registry - stop, +# orc-onboard - takes that lock shared on fd 7, refuses while the marker is +# `live` or `unknown`, and keeps fd 7 until its record is written +# (mam_update_admit_release). An update therefore either waits for that record +# and sees it, or publishes first and the entry refuses. The lock is a BSD +# flock taken by lib_py.pane_lock on the open fd: it ends when every process +# holding fd 7 has ended, however that ends, so nothing long-lived may inherit +# fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by +# MAM_UPDATE_ADMISSION_WAIT_S (default 120). +# +# mam_update_admit [workspace]: rc 0 admitted (fd 7 held), 1 refused (stderr). +mam_update_admit() { + local root="${1:-$WORKSPACE_ROOT}" + local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st + if ! { mkdir -p "$root/.mam" && exec 7>>"$lock"; }; then + echo "ERROR: cannot open the update admission lock $lock" >&2 + return 1 + fi + case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd 7 --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in + locked) ;; + timeout) + exec 7>&- + echo "ERROR: an update of $root holds its admission lock (MAM_UPDATE_ADMISSION_WAIT_S passed); try again when it has finished" >&2 + return 1 ;; + *) + exec 7>&- + echo "ERROR: the update admission lock $lock could not be decided (lib_py.pane_lock)" >&2 + return 1 ;; + esac + [ -f "$marker" ] || return 0 + declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" + st="$(_mam_lock_holder_state "$marker")" + case "$st" in + stale) + echo "NOTE: stale update marker $marker (its update is gone); ignored" >&2 + return 0 ;; + esac + exec 7>&- + echo "ERROR: an update of $root is in progress (marker $marker, holder $st); try again when it has finished" >&2 + return 1 +} + +mam_update_admit_release() { + exec 7>&- +} diff --git a/.agents/skills/lib_py/pane_lock.py b/.agents/skills/lib_py/pane_lock.py index bfc2cc9..62a4824 100644 --- a/.agents/skills/lib_py/pane_lock.py +++ b/.agents/skills/lib_py/pane_lock.py @@ -18,6 +18,9 @@ one lock per server from the choice to the claim. pane_lock.py acquire --fd N flock(LOCK_EX) on the inherited fd N; prints `locked`, or `timeout` once MAM_PANE_CLAIM_WAIT_S (default 120, minimum 1) has passed + [--shared] [--wait-env VAR] Issue #72: LOCK_SH instead, and the wait read + from VAR (same default and minimum) - the + update admission lock (lib.sh mam_update_admit) The lock is a BSD flock, which belongs to the open file on fd 9: it outlives this helper and ends when the last process holding that fd ends, however it @@ -63,11 +66,11 @@ def lock_path(session: Optional[str], env: Mapping[str, str]) -> str: return os.path.join(home, ".local", "state", "mam", "pane-lock", session + ".lock") -def wait_s(env: Mapping[str, str]) -> int: - return ints.positive_int(env.get("MAM_PANE_CLAIM_WAIT_S"), 1) or DEFAULT_WAIT_S +def wait_s(env: Mapping[str, str], var: str = "MAM_PANE_CLAIM_WAIT_S") -> int: + return ints.positive_int(env.get(var), 1) or DEFAULT_WAIT_S -def acquire(fd: int, wait: float) -> str: +def acquire(fd: int, wait: float, shared: bool = False) -> str: """`locked`, or `timeout` when the lock was not free within `wait` s. The deadline is on the monotonic clock; a step of the wall clock does not stretch or cut it. Only contention is waited out: any other error (a bad @@ -75,7 +78,7 @@ def acquire(fd: int, wait: float) -> str: deadline = time.monotonic() + wait while True: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) return "locked" except BlockingIOError: if time.monotonic() >= deadline: @@ -90,11 +93,13 @@ def main(argv: Optional[List[str]] = None) -> int: s.add_argument("--session", default="") s = sub.add_parser("acquire") s.add_argument("--fd", type=int, required=True) + s.add_argument("--shared", action="store_true") + s.add_argument("--wait-env", default="MAM_PANE_CLAIM_WAIT_S") a = p.parse_args(argv) if a.cmd == "path": print(lock_path(a.session, os.environ)) else: - print(acquire(a.fd, wait_s(os.environ))) + print(acquire(a.fd, wait_s(os.environ, a.wait_env), a.shared)) return 0 diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index fecf5cc..f859216 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -181,7 +181,7 @@ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace | Exit | Meaning | |---|---| | 0 | Created; or `--dry-run` | -| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | +| 1 | Pre-flight failure, nothing was spawned: workspace not a directory, herdr not installed, agent CLI not in `PATH`, agent not authenticated, or a same-cwd session of the same Class A agent did not record its conversation ID within `MAM_ID_ORDER_WAIT_S` (default 600), or an update of the workspace is in progress (`multi-agent-mux-update`). After spawn: the task brief could not be written or the instruction injection failed (the session is rolled back) | | 2 | Bad arguments; control-plane binding refused for a worktree; whitespace in a control-plane path that must be forwarded | | 3 | A herdr session of that name already exists (`multi-agent-mux-resume`, or `multi-agent-mux-stop` first) | | 4 | The YAML already has a `running` row of that name. The YAML write raises it after spawn, and the herdr session is rolled back. A row the monitor registered for this very pane while it was being created is replaced instead | diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 18cfa3b..5899963 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -260,7 +260,7 @@ spawn() { SESSION_UUID="" CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")" [ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2 - nohup "$WRAPPER" >/dev/null 2>&1 & + nohup "$WRAPPER" >/dev/null 2>&1 7>&- & disown else HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" @@ -285,6 +285,9 @@ if [ "$DRY_RUN" = "1" ]; then fi # Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait). mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1 +# Issue #72: no new session while an update runs; held until the row is written +# (lib.sh mam_update_admit). +mam_update_admit || exit 1 HERDR_EPOCH=$(date +%s) NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') spawn @@ -638,6 +641,7 @@ snap['cwd'] = os.environ['PANE_CWD'] print(f"appended: {name}", flush=True) PYEOF ROW_APPENDED=1 +mam_update_admit_release echo echo "=== created ===" diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index e77146a..6223140 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -301,6 +301,10 @@ cmd_submit() { local reg_dry=() if [[ "$DRY_RUN" == "1" ]]; then reg_dry=(--dry-run); fi + # Issue #72: no job is registered while an update of the target's workspace + # runs; held until the record is written (lib.sh mam_update_admit). A + # dry-run record is never dispatched and is not counted by the update. + if [[ "$DRY_RUN" != "1" ]]; then mam_update_admit "$WORKSPACE_ROOT" || exit 1; fi # 1) register job (prints the new job id) JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ "${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \ @@ -310,6 +314,7 @@ cmd_submit() { --job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \ --max-iterations "$MAX_ITERATIONS" ${reg_parent[@]+"${reg_parent[@]}"} ${reg_dry[@]+"${reg_dry[@]}"})" echo "registered job: $JOB_ID" + [[ "$DRY_RUN" == "1" ]] || mam_update_admit_release # 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring) local job_dir="$REGISTRY_DIR/$JOB_ID" diff --git a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh index 7c481b3..d8369ce 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -244,6 +244,9 @@ log_error() { echo -e "\033[1;31m[✗]\033[0m $1" } +# Issue #72: no loop starts while an update runs; held until the loop marker +# is published (lib.sh mam_update_admit), released before the loop body runs. +mam_update_admit "$MAM_REAL_ROOT" || exit 1 _mam_acquire_rc=0 mam_acquire_loop_lock "$MAM_LOOP_MARKER" || _mam_acquire_rc=$? case "$_mam_acquire_rc" in @@ -262,6 +265,7 @@ case "$_mam_acquire_rc" in ;; esac trap _mam_release_guard EXIT INT TERM HUP +mam_update_admit_release rm -f "$MAM_REAL_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job".*.tmp 2>/dev/null || true # --all-reviewer silently takes precedence over an explicit --reviewer list; diff --git a/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh b/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh index 9235ea1..ccd447d 100755 --- a/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh +++ b/.agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh @@ -257,6 +257,11 @@ if [ "$DRY_RUN" = true ]; then exit 0 fi +# Issue #72 (U2): no registry write while an update runs - a rollback would +# restore the registry without it. Held until the write is done (lib.sh +# mam_update_admit). +mam_update_admit || exit 1 + # Issue #13: the orchestrator has no herdr_sessions row, so stop_session.sh # --purge-conversation can never reach its conversation. Purge FIRST, through # the adapter (`lib_py.agents purge`): a refusal because the conversation is @@ -304,6 +309,7 @@ elif op == "remove": uuids = [u for u in uuids if u != target] d["orchestrator_uuids"] = uuids PYEOF +mam_update_admit_release case "$MODE" in add) verb="added" ;; diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 634345d..63f7974 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -75,11 +75,11 @@ bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace | Exit | Meaning | |---|---| | 0 | Resumed; or already running, so only the row was updated (no attach); or `--dry-run` | -| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), or agent binary missing/not executable. **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | +| 1 | From the checks before spawn, nothing was changed: no saved session for claude/grok (use `multi-agent-mux-create` first), the row's id is also claimed by another row, a same-cwd Class A session did not record its id within `MAM_ID_ORDER_WAIT_S` (default 600), agent binary missing/not executable, or an update of the workspace is in progress (`multi-agent-mux-update`). **The YAML update after spawn (`update_yaml_resumed.sh`) also exits 1** (YAML not found) | | 2 | From the checks before spawn, nothing was changed: bad arguments, `--workspace` differs from the original cwd or it no longer exists, control-plane binding refused, whitespace in the control-plane path. **The YAML update after spawn also exits 2** (bad arguments) | | other | A later step failed (herdr spawn, start-up dialogs, YAML update) and its code is passed through. resume does not roll back | -A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-243). +A non-zero exit after the spawn can leave the herdr session running while the row is still `stopped`. Before retrying or creating a new session, check with `multi-agent-mux-status`. The `ERROR:` line on stderr tells the two apart: the pre-spawn checks' messages are in `resume_session.sh` (lines 50-259). ## Original cwd only (Issue #21 phase 2) diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index cd76362..0668256 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -172,12 +172,15 @@ if [ "$HERDR_ALIVE" = "1" ]; then echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" exit 0 fi + # Issue #72: no registry write while an update runs (lib.sh mam_update_admit). + mam_update_admit || exit 1 echo "herdr '$SESSION_NAME' already running." # Just update YAML to make sure it's set to running CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --already-running \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} + mam_update_admit_release _ensure_monitor_daemon exit 0 fi @@ -251,6 +254,10 @@ if [ "${DRY_RUN:-0}" = "1" ]; then exit 0 fi +# Issue #72: no new session while an update runs; held until the row is written +# (lib.sh mam_update_admit). +mam_update_admit || exit 1 + # Issue #63: wait if another same-cwd session of this post-discovered agent # is still being created or has not recorded its id yet (lib.sh mam_id_order_wait). mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" "$UUID" || exit 1 @@ -287,6 +294,7 @@ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_r --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${DROP_FLAG:+$DROP_FLAG} \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} +mam_update_admit_release _ensure_monitor_daemon echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index cd89e89..eb8db61 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -58,7 +58,7 @@ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \ | Exit | Meaning | |---|---| | 0 | Stopped; or already `stopped` (no-op, except with `--purge-conversation`, which still purges) | -| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, or the session is neither registered nor on the herdr server. **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | +| 1 | From the checks before the stop, nothing was changed: YAML not found, its row could not be read, the session is neither registered nor on the herdr server, or an update of the workspace is in progress (`multi-agent-mux-update`). **The registry update after the stop also exits 1** (the row disappeared from the YAML during the run) | | 2 | Bad arguments (including the retired `--mode` / `--capture-id` / `--graceful`), or `--agent` omitted and not resolvable | | 3 | `--purge-conversation` without `--yes` | | 4 | Purge aborted: the herdr session, or an agent process still holding the conversation, survived the kill chain. Records preserved: the registry row and the conversation files are untouched, though the session itself may already be down | diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 50067a5..edeaed6 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -100,6 +100,11 @@ fi [ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; } [ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; } +# Issue #72 (U2): no registry write while an update runs - a rollback would +# restore the registry without it. Held until the row is written (lib.sh +# mam_update_admit). +mam_update_admit || exit 1 + # Implement the purging- file lock mechanism if [ "$PURGE" = "1" ]; then touch "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME" @@ -453,6 +458,7 @@ if purge: else: print(f"updated: {name} status={target['status']}", flush=True) PYEOF +mam_update_admit_release delegate_publish_event "$DELEGATE_JOB_ID" cancelled "session stopped by operator before job completion" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index c04ea77..2e321dd 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -11,7 +11,7 @@ "kind": "example", "evidence": [ "multi-agent-mux-create/scripts/create_session.sh:99", - "multi-agent-mux-create/scripts/create_session.sh:356" + "multi-agent-mux-create/scripts/create_session.sh:359" ], "mismatch": null, "note": "HERDR_SESSION_NAME environment example" @@ -67,7 +67,7 @@ "class": "c", "kind": "example", "evidence": [ - "multi-agent-mux-create/scripts/create_session.sh:514-541" + "multi-agent-mux-create/scripts/create_session.sh:517-544" ], "mismatch": null, "note": "Output format: the row create_session.sh writes" @@ -122,7 +122,7 @@ "class": "c", "kind": "invocation", "evidence": [ - "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1046-1057" + "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1051-1062" ], "mismatch": null, "note": "CLI reference; subcommands submit/status/list/verify/wait/logs match main()" @@ -433,7 +433,7 @@ "class": "c", "kind": "diagram", "evidence": [ - "multi-agent-mux-stop/scripts/stop_session.sh:245-259" + "multi-agent-mux-stop/scripts/stop_session.sh:250-264" ], "mismatch": null, "note": "state machine" @@ -477,10 +477,10 @@ "multi-agent-mux-resume/scripts/resume_session.sh:111-130", "multi-agent-mux-resume/scripts/resume_session.sh:133-144", "multi-agent-mux-resume/scripts/resume_session.sh:147-170", - "multi-agent-mux-resume/scripts/resume_session.sh:185-210", - "multi-agent-mux-resume/scripts/resume_session.sh:258", - "multi-agent-mux-resume/scripts/resume_session.sh:262", - "multi-agent-mux-resume/scripts/resume_session.sh:268-271", + "multi-agent-mux-resume/scripts/resume_session.sh:188-213", + "multi-agent-mux-resume/scripts/resume_session.sh:265", + "multi-agent-mux-resume/scripts/resume_session.sh:269", + "multi-agent-mux-resume/scripts/resume_session.sh:275-278", "lib_py/agents/adapters/claude.py:109" ], "mismatch": [ @@ -504,8 +504,8 @@ "class": "a", "evidence": [ "multi-agent-mux-stop/scripts/stop_session.sh:101", - "multi-agent-mux-stop/scripts/stop_session.sh:123-193", - "multi-agent-mux-stop/scripts/stop_session.sh:245-249" + "multi-agent-mux-stop/scripts/stop_session.sh:128-198", + "multi-agent-mux-stop/scripts/stop_session.sh:250-254" ], "mismatch": [ "T1: the doc offered --force-no-yaml, which no script accepts" @@ -547,8 +547,8 @@ "multi-agent-mux-create/scripts/create_session.sh:166-168", "multi-agent-mux-create/scripts/create_session.sh:207", "multi-agent-mux-create/scripts/create_session.sh:259-274", - "multi-agent-mux-create/scripts/create_session.sh:361-398", - "multi-agent-mux-create/scripts/create_session.sh:410-412" + "multi-agent-mux-create/scripts/create_session.sh:364-401", + "multi-agent-mux-create/scripts/create_session.sh:413-415" ], "mismatch": [ "C1: an existing session is exit 1 in the doc, exit 3 in the script", @@ -568,7 +568,7 @@ "base_sha": "f71be0f26fb8", "class": "a", "evidence": [ - "multi-agent-mux-create/scripts/create_session.sh:486-604" + "multi-agent-mux-create/scripts/create_session.sh:489-607" ], "mismatch": [], "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role ", @@ -601,7 +601,7 @@ "other" ], "passthrough": { - "1": "multi-agent-mux-stop/scripts/stop_session.sh:384" + "1": "multi-agent-mux-stop/scripts/stop_session.sh:389" } }, "multi-agent-mux-create/scripts/create_session.sh": { @@ -619,7 +619,7 @@ "other" ], "passthrough": { - "4": "multi-agent-mux-create/scripts/create_session.sh:546" + "4": "multi-agent-mux-create/scripts/create_session.sh:549" } } }, diff --git a/tests/test_issue72_admission.py b/tests/test_issue72_admission.py new file mode 100644 index 0000000..7df3e44 --- /dev/null +++ b/tests/test_issue72_admission.py @@ -0,0 +1,196 @@ +"""Issue #72 (decision 8, U2): while an update runs, the entries that start +something or write the registry refuse - create, resume, run_loop.sh, delegate +submit, stop (plain and --purge-conversation) and orc-onboard - and change +nothing. A marker its update left behind (`stale`) refuses nothing. + +The marker is .mam/update-in-progress as the update skill publishes it (pid + +lstart, loop_lock.sh); `live` here is a sleeping helper process's. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from pathlib import Path + +import pytest +import yaml + +UUID = "11111111-2222-3333-4444-555555555555" +ORC = "99999999-8888-7777-6666-555555555555" +ORC_OLD = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +@pytest.fixture +def marker(mam_sandbox): + procs = [] + + def make(live: bool): + p = mam_sandbox / ".mam" / "update-in-progress" + if live: + proc = subprocess.Popen(["sleep", "120"], start_new_session=True) + procs.append(proc) + ls = " ".join(subprocess.run(["ps", "-p", str(proc.pid), "-o", "lstart="], + capture_output=True, text=True).stdout.split()) + p.write_text("pid=%d\nlstart=%s\nstarted=x\n" % (proc.pid, ls)) + else: + dead = subprocess.Popen(["true"]) + dead.wait() + p.write_text("pid=%d\nlstart=Thu Jan 1 00:00:00 1970\nstarted=x\n" % dead.pid) + return p + + yield make + for proc in procs: + proc.kill() + proc.wait() + + +def _seed(sandbox, rows, **top): + lib = sandbox / ".agents" / "skills" / "lib.sh" + mutation = "d['herdr_sessions'] = %r\n" % (rows,) + "".join("d[%r] = %r\n" % kv for kv in top.items()) + res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)], + input=mutation, capture_output=True, text=True) + assert res.returncode == 0, res.stderr + + +def _registry(sandbox): + out = {} + for f in ("agent-sessions.yaml", "agent-sessions.db"): + p = sandbox / ".mam" / f + out[f] = hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None + return out + + +def _script(sandbox, rel): + return str(sandbox / ".agents" / "skills" / rel) + + +def _run(sandbox, argv, env=None, timeout=120): + return subprocess.run(argv, capture_output=True, text=True, cwd=str(sandbox), timeout=timeout, + env=dict(os.environ, **(env or {})), stdin=subprocess.DEVNULL) + + +def _refused(res, sandbox): + assert res.returncode != 0, res.stdout + res.stderr + assert "an update of %s is in progress" % sandbox in res.stderr, res.stdout + res.stderr + + +def _row(name, status="running", agent="claude"): + r = {"name": name, "status": status, "role": "creator", "herdr_session": "default", "herdr_server": "default", + "pane": {"cwd": "", "cmd": agent}, "agent": agent} + if agent == "claude": + r["claude_session_id_own"] = UUID + return r + + +def _agent(cwd, pane): + return {"agent": "claude", "status": "running", "cwd": str(cwd), "workspace_id": "w1", + "pid": 9999, "pane_id": pane, "command": "claude", "buffer": "Ready"} + + +def _set_mock(state_file, **kw): + st = json.loads(Path(state_file).read_text()) + st.update(kw) + Path(state_file).write_text(json.dumps(st)) + + +def test_create_refuses_while_an_update_runs(mam_sandbox, mock_herdr, mock_agents, marker): + marker(True) + before = _registry(mam_sandbox) + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-create/scripts/create_session.sh"), + "--workspace", str(mam_sandbox), "--agent", "claude", "--role", "creator", + "--session", "gated-create", "--no-onboard"], timeout=300) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned" + + +def test_resume_refuses_on_the_spawn_and_the_alive_path(mam_sandbox, mock_herdr, mock_agents, marker): + _seed(mam_sandbox, [dict(_row("gated-agy", status="stopped", agent="agy"), pane={"cwd": str(mam_sandbox), "cmd": "agy"})]) + marker(True) + before = _registry(mam_sandbox) + script = _script(mam_sandbox, "multi-agent-mux-resume/scripts/resume_session.sh") + res = _run(mam_sandbox, ["bash", script, "--workspace", str(mam_sandbox), "--agent", "agy", + "--session", "gated-agy"], timeout=300) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned" + + _set_mock(mock_herdr, agents={"gated-agy": _agent(mam_sandbox, "w1:p9")}) + res = _run(mam_sandbox, ["bash", script, "--workspace", str(mam_sandbox), "--agent", "agy", + "--session", "gated-agy"], timeout=300) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + + +def test_run_loop_refuses_before_its_loop_marker(mam_sandbox, marker): + marker(True) + loop_marker = mam_sandbox / ".mam" / "loop-guard-active" + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-loop/scripts/run_loop.sh"), + "--creator", "c", "--task", "t"], + env={"MAM_LOOP_NO_FREEZE": "1", "MAM_REAL_ROOT": str(mam_sandbox)}) + _refused(res, mam_sandbox) + assert not loop_marker.exists() + + +def test_delegate_submit_registers_no_job(mam_sandbox, mock_herdr, marker): + marker(True) + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"), + "submit", "--agent", "claude-code", "--agent-session", "herdr:anyone", "--prompt", "hi"]) + _refused(res, mam_sandbox) + jobs = mam_sandbox / ".mam" / "jobs" + assert not list(jobs.glob("*.json")) if jobs.exists() else True + + +@pytest.mark.parametrize("purge", [False, True]) +def test_stop_refuses_and_changes_nothing(mam_sandbox, mock_herdr, marker, purge): + _seed(mam_sandbox, [_row("gated-stop")]) + _set_mock(mock_herdr, agents={"gated-stop": _agent(mam_sandbox, "w1:p8")}) + marker(True) + before = _registry(mam_sandbox) + extra = ["--purge-conversation", "--yes"] if purge else [] + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-stop/scripts/stop_session.sh"), + "--session", "gated-stop", "--agent", "claude", *extra]) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + assert "gated-stop" in json.loads(Path(mock_herdr).read_text())["agents"], "the session was not touched" + assert not (mam_sandbox / ".mam" / "purging-gated-stop").exists() + + +def test_orc_onboard_refuses_and_changes_nothing(mam_sandbox, marker): + _seed(mam_sandbox, [], orchestrator_uuids=[ORC_OLD]) + marker(True) + before = _registry(mam_sandbox) + script = _script(mam_sandbox, "multi-agent-mux-orc-onboard/scripts/orc_onboard.sh") + for args in (["--uuid", ORC], ["--remove", ORC_OLD]): + res = _run(mam_sandbox, ["bash", script, *args]) + _refused(res, mam_sandbox) + assert _registry(mam_sandbox) == before + + +def test_a_stale_marker_refuses_nothing(mam_sandbox, mock_herdr, marker): + _seed(mam_sandbox, [_row("stale-stop")], orchestrator_uuids=[]) + _set_mock(mock_herdr, agents={"stale-stop": _agent(mam_sandbox, "w1:p7")}) + marker(False) + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-orc-onboard/scripts/orc_onboard.sh"), + "--uuid", ORC]) + assert res.returncode == 0, res.stdout + res.stderr + assert "stale update marker" in res.stderr + res = _run(mam_sandbox, ["bash", _script(mam_sandbox, "multi-agent-mux-stop/scripts/stop_session.sh"), + "--session", "stale-stop", "--agent", "claude"]) + assert res.returncode == 0, res.stdout + res.stderr + d = yaml.safe_load((mam_sandbox / ".mam" / "agent-sessions.yaml").read_text()) + assert d["orchestrator_uuids"] == [ORC] + assert {r["name"]: r["status"] for r in d["herdr_sessions"]} == {"stale-stop": "stopped"} + + +def test_the_admission_lock_is_released_after_the_record(mam_sandbox, marker): + """An entry takes the lock shared and drops it once its record is written: + a later exclusive taker (the update) gets it at once.""" + lib = mam_sandbox / ".agents" / "skills" / "lib.sh" + res = subprocess.run(["bash", "-c", 'source "$1" && mam_update_admit && mam_update_admit_release && ' + 'exec 7>>"$WORKSPACE_ROOT/.mam/update-admission.lock" && ' + 'MAM_UPDATE_ADMISSION_WAIT_S=1 python3 -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S', + "_", str(lib)], capture_output=True, text=True, timeout=60) + assert res.returncode == 0 and res.stdout.strip() == "locked", res.stdout + res.stderr -- 2.54.0 From 47fe24e07c5a7b082a204e8da5a8574cb8551a7c Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:06:57 +0000 Subject: [PATCH 04/16] feat: multi-agent-mux-update skill and in-place install (Issue #72) 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 --- .agents/skills/lib_py/update.py | 315 ++++++++ .../skills/multi-agent-mux-update/SKILL.md | 30 + .../scripts/install_phase.sh | 122 +++ .../scripts/post_update.sh | 47 ++ .../multi-agent-mux-update/scripts/update.sh | 257 +++++++ README.ko.md | 5 +- README.md | 5 +- deploy/README.md | 6 +- deploy/gitea-ci.yml | 2 +- deploy/install.sh | 85 ++- deploy/install_mam.sh | 8 +- deploy/remove.sh | 1 + deploy/update.sh | 269 ------- tests/data/skill_code_blocks.json | 14 + tests/test_deploy_layout.py | 26 +- tests/test_issue42_skill_blocks.py | 4 +- tests/test_issue72_installer.py | 206 +++++ tests/test_issue72_update.py | 710 ++++++++++++++++++ tests/test_issue8_update_state.py | 22 +- 19 files changed, 1820 insertions(+), 314 deletions(-) create mode 100644 .agents/skills/lib_py/update.py create mode 100644 .agents/skills/multi-agent-mux-update/SKILL.md create mode 100755 .agents/skills/multi-agent-mux-update/scripts/install_phase.sh create mode 100755 .agents/skills/multi-agent-mux-update/scripts/post_update.sh create mode 100755 .agents/skills/multi-agent-mux-update/scripts/update.sh delete mode 100644 deploy/update.sh create mode 100644 tests/test_issue72_installer.py create mode 100644 tests/test_issue72_update.py diff --git a/.agents/skills/lib_py/update.py b/.agents/skills/lib_py/update.py new file mode 100644 index 0000000..b9372e4 --- /dev/null +++ b/.agents/skills/lib_py/update.py @@ -0,0 +1,315 @@ +"""Decisions behind the multi-agent-mux-update skill (Issue #72). + +update.sh keeps the orchestration in shell (phase 1: this tree's code) and asks +this module what needs parsing. Standard library only, Python 3.9. + + python -m lib_py.update precheck --workspace W --herdr H + What refuses the update (comment 921, decisions 1 and 7): the running + rows of the registry (MAM_STATE_JSON, from lib.sh `load_state_json + strict`), every live herdr agent whose cwd is inside W on any server + `herdr session list --json` reports running (read only: the real herdr H, + never the shim, so no server is started), and the open jobs. The agent + that called the update - by (server, pane): pane_lock.caller_server and + HERDR_PANE_ID - is not counted, nor its row and its one running job. + Prints one line per blocker with how to clear it; rc 0 nothing blocks, + 1 something does (a herdr or registry that cannot be read included). + python -m lib_py.update monitor --workspace W + JSON {"pids": [...], "lock": "free"|"held"}: the monitor daemons + daemon.find_running recognises, and whether .mam/monitor.lock is held. + python -m lib_py.update backup-registry --workspace W --dest D + Copies the registry into D under the registry's own write lock + (agent-sessions.db.lock, as atomic_yaml takes it): the DB through the + SQLite backup API (commits still in -wal included), the YAML and its + .bak as files. Prints the names it copied. +""" +from __future__ import annotations + +import argparse +import fcntl +import glob +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +from typing import Dict, Iterable, List, Mapping, Optional, Tuple + +from lib_py.agents.sanitize import sanitize_herdr_agent_name +from lib_py.delegation import daemon +from lib_py.delegation.engine import TERMINAL_STATUSES +from lib_py.pane_lock import caller_server + +REGISTRY_FILES = ("agent-sessions.yaml", "agent-sessions.yaml.bak", "agent-sessions.db") +HERDR_TIMEOUT_S = 20 + + +def _real(p: str) -> str: + return os.path.realpath(p) + + +def _inside(path: str, root: str) -> bool: + if not path: + return False + p, r = _real(path), _real(root).rstrip(os.sep) + return p == r or p.startswith(r + os.sep) + + +def row_server(row: dict) -> str: + return row.get("herdr_session") or row.get("herdr_server") or "default" + + +# --------------------------------------------------------------------------- herdr + +class HerdrError(Exception): + pass + + +def _herdr_json(herdr: str, args: List[str], env: Mapping[str, str]) -> dict: + # A pane's own scope ids would narrow the answer to the caller's workspace. + cenv = {k: v for k, v in env.items() + if k not in ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")} + try: + res = subprocess.run([herdr, *args], env=cenv, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=HERDR_TIMEOUT_S) + except (OSError, subprocess.SubprocessError) as exc: + raise HerdrError("`herdr %s` failed: %s" % (" ".join(args), exc)) + if res.returncode != 0: + raise HerdrError("`herdr %s` exited %d: %s" % (" ".join(args), res.returncode, + (res.stderr or "").strip()[:200])) + try: + out = res.stdout + return json.loads(out[out.index("{"):]) + except ValueError: + raise HerdrError("`herdr %s` printed no JSON" % " ".join(args)) + + +def running_servers(herdr: str, env: Mapping[str, str]) -> List[str]: + d = _herdr_json(herdr, ["session", "list", "--json"], env) + sessions = d.get("sessions") + if not isinstance(sessions, list): + raise HerdrError("`herdr session list --json` has no sessions list") + return [s["name"] for s in sessions + if isinstance(s, dict) and s.get("running") and s.get("name")] + + +def server_agents(herdr: str, server: str, env: Mapping[str, str]) -> List[dict]: + d = _herdr_json(herdr, ["--session", server, "agent", "list"], env) + agents = (d.get("result") or {}).get("agents") + if not isinstance(agents, list): + raise HerdrError("`herdr --session %s agent list` has no agents list" % server) + return [a for a in agents if isinstance(a, dict)] + + +# --------------------------------------------------------------------------- jobs + +def _job_records(d: str) -> Iterable[Tuple[str, Optional[dict]]]: + for p in sorted(glob.glob(os.path.join(d, "*.json"))): + try: + with open(p, encoding="utf-8") as fh: + rec = json.load(fh) + except (OSError, ValueError): + yield p, None + continue + yield p, rec if isinstance(rec, dict) else None + + +def _is_open(rec: dict) -> bool: + return rec.get("status") not in TERMINAL_STATUSES and rec.get("dry_run") is not True + + +def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict]: + """Every open record (not completed/error/cancelled, not dry_run) in this + workspace's .mam/jobs and MAM_JOBS_DIR; in another row's + control_plane.mam_jobs_dir only those addressed to a row of this registry. + A record that cannot be read is open (path only).""" + own, seen = [], set() + for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or ""): + if c and os.path.isdir(c) and _real(c) not in seen: + seen.add(_real(c)) + own.append(c) + rows = state.get("herdr_sessions") or [] + names = {"herdr:%s" % s.get("name") for s in rows if s.get("name")} + other = [] + for s in rows: + c = (s.get("control_plane") or {}).get("mam_jobs_dir") or "" + if c and os.path.isdir(c) and _real(c) not in seen: + seen.add(_real(c)) + other.append(c) + out = [] + for dirs, addressed_only in ((own, False), (other, True)): + for d in dirs: + for path, rec in _job_records(d): + if rec is None: + out.append({"path": path, "unreadable": True}) + elif _is_open(rec) and (not addressed_only or rec.get("agent_session") in names): + out.append({"path": path, "job_id": rec.get("job_id") or "", + "agent_session": rec.get("agent_session") or "", + "status": rec.get("status"), "registry_dir": d}) + return out + + +# --------------------------------------------------------------------------- precheck + +def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> List[str]: + """The lines that refuse the update; [] when nothing does.""" + blockers: List[str] = [] + stop = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s" + " (resume it after the update with multi-agent-mux-resume)") + + # The caller (decision 7): its server and pane, both or nothing. + me_server, me_pane = caller_server(env), env.get("HERDR_PANE_ID") or "" + me_name: Optional[str] = None + me_found = False + + agents_by_server: Dict[str, List[dict]] = {} + try: + for server in running_servers(herdr, env): + agents_by_server[server] = server_agents(herdr, server, env) + except HerdrError as exc: + blockers.append("herdr: cannot list the live agents (%s); fix herdr and try again" % exc) + + if me_server and me_pane: + for a in agents_by_server.get(me_server, []): + if a.get("pane_id") == me_pane: + me_found = True + me_name = a.get("name") or None # a nameless pane is excluded by pane only + break + + rows = state.get("herdr_sessions") or [] + me_row = None + if me_name: + for s in rows: + if (s.get("status") == "running" and row_server(s) == me_server + and s.get("name") and sanitize_herdr_agent_name(s["name"]) == me_name): + me_row = s + break + + for s in rows: + if s.get("status") == "running" and s is not me_row: + blockers.append("session %s is running (server %s); stop it: %s" + % (s.get("name"), row_server(s), stop % s.get("name"))) + + row_keys = {(row_server(s), sanitize_herdr_agent_name(s["name"])) + for s in rows if s.get("status") == "running" and s.get("name")} + for server, agents in agents_by_server.items(): + for a in agents: + if me_found and server == me_server and a.get("pane_id") == me_pane: + continue + if not (_inside(a.get("cwd") or "", workspace) + or _inside(a.get("foreground_cwd") or "", workspace)): + continue + name = a.get("name") or "" + if name and (server, name) in row_keys: + continue # already listed as its running row + blockers.append("herdr agent %s (pane %s, server %s) runs in %s; stop it: %s" + % (name or "", a.get("pane_id"), server, a.get("cwd"), + stop % name if name else + "herdr --session %s pane close %s" % (server, a.get("pane_id")))) + + jobs = open_jobs(workspace, state, env) + if me_row is not None: + mine = [j for j in jobs if not j.get("unreadable") + and j.get("agent_session") == "herdr:%s" % me_row.get("name") + and j.get("status") == "running"] + if len(mine) == 1: + jobs = [j for j in jobs if j is not mine[0]] + for j in jobs: + if j.get("unreadable"): + blockers.append("job record %s cannot be read; check it, then move or delete it by hand" + % j["path"]) + else: + blockers.append("job %s (%s, %s) is open; wait for it to end or close it: python " + ".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py" + " --registry-dir %s close --job %s --request cancelled" + % (j["job_id"], j["agent_session"] or "-", j["status"], + j["registry_dir"], j["job_id"])) + + if blockers and not me_found: + blockers.append("note: no herdr agent was found at the caller's server %r and pane %r, so" + " nothing was excluded as the calling agent" % (me_server, me_pane)) + return blockers + + +# --------------------------------------------------------------------------- monitor + +def monitor_state(workspace: str) -> dict: + pids = daemon.find_running(daemon.monitor_script_path(workspace)) + lock = os.path.join(workspace, ".mam", "monitor.lock") + held = False + if os.path.exists(lock): + with open(lock, "a") as fh: + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fh, fcntl.LOCK_UN) + except BlockingIOError: + held = True + return {"pids": pids, "lock": "held" if held else "free"} + + +# --------------------------------------------------------------------------- registry backup + +def backup_registry(workspace: str, dest: str) -> List[str]: + mam = os.path.join(workspace, ".mam") + db = os.path.join(mam, "agent-sessions.db") + os.makedirs(dest, exist_ok=True) + copied = [] + with open(db + ".lock", "w") as lk: + fcntl.flock(lk, fcntl.LOCK_EX) + try: + if os.path.exists(db): + src = sqlite3.connect(db, timeout=60.0) + dst = sqlite3.connect(os.path.join(dest, "agent-sessions.db")) + try: + src.backup(dst) + finally: + dst.close() + src.close() + copied.append("agent-sessions.db") + for name in REGISTRY_FILES: + p = os.path.join(mam, name) + if name != "agent-sessions.db" and os.path.exists(p): + shutil.copy2(p, os.path.join(dest, name)) + copied.append(name) + finally: + fcntl.flock(lk, fcntl.LOCK_UN) + return copied + + +def _state() -> dict: + return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {} + + +def main(argv: Optional[List[str]] = None) -> int: + p = argparse.ArgumentParser(prog="lib_py.update") + sub = p.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("precheck") + s.add_argument("--workspace", required=True) + s.add_argument("--herdr", required=True) + s = sub.add_parser("monitor") + s.add_argument("--workspace", required=True) + s = sub.add_parser("backup-registry") + s.add_argument("--workspace", required=True) + s.add_argument("--dest", required=True) + a = p.parse_args(argv) + ws = _real(a.workspace) + if a.cmd == "precheck": + try: + state = _state() + except ValueError as exc: + print("registry: cannot parse MAM_STATE_JSON (%s)" % exc) + return 1 + lines = precheck(ws, state, a.herdr, os.environ) + for line in lines: + print(line) + return 1 if lines else 0 + if a.cmd == "monitor": + print(json.dumps(monitor_state(ws))) + return 0 + for name in backup_registry(ws, a.dest): + print(name) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/multi-agent-mux-update/SKILL.md b/.agents/skills/multi-agent-mux-update/SKILL.md new file mode 100644 index 0000000..c8b5829 --- /dev/null +++ b/.agents/skills/multi-agent-mux-update/SKILL.md @@ -0,0 +1,30 @@ +--- +name: multi-agent-mux-update +description: "Update an installed MAM workspace in place to the latest version. Refuses while a session, an open job, a review loop or an unknown monitor is live; stops the monitor daemon and starts it again afterwards; rolls everything back if the install fails; converts the registry with the newly installed code. Use instead of the removed .mam_deploy/update.sh." +version: 4.11.0 +author: godopu +license: MIT +platforms: [linux, macos] +environments: [terminal, herdr] +metadata: + hermes: + tags: [update, install, registry, migration] + related_skills: [multi-agent-mux-stop, multi-agent-mux-resume, multi-agent-mux-monitor] +--- + +# Multi-Agent Update + +```bash +bash .agents/skills/multi-agent-mux-update/scripts/update.sh --yes +``` + +| Item | Behaviour | +|---|---| +| Where | An installed workspace only (`.mam_deploy/remove.sh` present). A git checkout updates with `git pull`. | +| Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required. There is no force option. | +| Refused when | a running registry row, a live herdr agent whose cwd is in the workspace (any running server), an open job (unreadable job JSON included, `dry_run` excluded), a live loop marker, `.mam/monitor.lock` held by a monitor the skills did not start, or another update. The calling agent (its herdr server + pane, row and one running job) is not counted. Each refusal lists the cleanup; nothing is changed. | +| During the update | create, resume, `run_loop.sh`, delegate submit, stop and orc-onboard refuse (`.mam/update-in-progress`, `.mam/update-admission.lock`). | +| Monitor | stopped first, started again afterwards only if it ran (`MAM_NO_SUBSCRIBER_DAEMON=1`: not started). | +| Install | the installer at `MAM_INSTALLER_URL` (default: Gitea `main`) runs in place; `.mam` is kept. On failure `.agents/`, `.mam_deploy/`, root files, `.venv`, the installer's `.mam` files and the registry are restored from `.mam/update-backup/`. | +| Registry | converted by the new code (`lib_py.registry_format`); a failed conversion keeps the install and exits nonzero. | +| Exit | `0` updated, `1` refused or failed, `2` bad arguments. | diff --git a/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh b/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh new file mode 100755 index 0000000..5741ffb --- /dev/null +++ b/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# install_phase.sh — multi-agent-mux-update, the part that changes the tree (Issue #72, decision 5). +# +# install_phase.sh run +# install_phase.sh restore [--skip-registry] +# +# update.sh copies this file outside the workspace and execs the copy (same +# pid and start time, so its .mam/update-in-progress marker stays live). From +# here on the tree is being replaced, so this calls no lib.sh or lib_py: it runs +# the installer in place, and on failure restores .mam/update-backup/ and starts +# the monitor again with the restored (old, whole) tree's start_watchdog. On +# success phase 2 is the NEW tree's post_update.sh, in a child process. It ends +# by removing the backup and the marker (only its own: pid + lstart). +# Must run on macOS bash 3.2. +set -uo pipefail + +MODE="${1:-}" +WS="${2:-}" +[ -n "$MODE" ] && [ -d "$WS" ] || { echo "usage: $0 run|restore ..." >&2; exit 2; } +BK="$WS/.mam/update-backup" + +set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; } + +# Puts back every backed-up item, removes the ones that did not exist before, +# then (unless --skip-registry) the registry: the DB from the backup, its +# -wal/-shm removed (macOS keeps them after close). +restore() { + local rel n + [ -f "$BK/items" ] || { echo "ERROR: $BK/items is missing" >&2; return 1; } + while IFS= read -r rel; do + [ -n "$rel" ] || continue + rm -rf "${WS:?}/$rel" || return 1 + grep -Fqx -- "$rel" "$BK/absent" 2>/dev/null && continue + mkdir -p "$(dirname "$WS/$rel")" && cp -pPR "$BK/files/$rel" "$WS/$rel" || return 1 + done < "$BK/items" + [ "${1:-}" = "--skip-registry" ] && return 0 + for n in agent-sessions.yaml agent-sessions.yaml.bak agent-sessions.db; do + rm -f "$WS/.mam/$n" || return 1 + if [ -f "$BK/registry/$n" ]; then + cp -p "$BK/registry/$n" "$WS/.mam/$n" || return 1 + fi + done + rm -f "$WS/.mam/agent-sessions.db-wal" "$WS/.mam/agent-sessions.db-shm" +} + +release_marker() { + local m="$WS/.mam/update-in-progress" pid lstart cur + [ -f "$m" ] || return 0 + pid="$(grep '^pid=' "$m" | head -n 1 | cut -d= -f2-)" + lstart="$(grep '^lstart=' "$m" | head -n 1 | cut -d= -f2-)" + cur="$(ps -p $$ -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//')" + if [ "$pid" = "$$" ] && [ "$lstart" = "$cur" ]; then + rm -f "$m" + fi +} + +restart_old_monitor() { + [ "$1" = "1" ] || return 0 + if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then + echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" + return 0 + fi + local pid + if pid="$(bash -c 'source "$1/.agents/skills/lib.sh" && start_watchdog update "$1"' _ "$WS")"; then + echo "monitor: restarted (PID $pid)" + else + echo "ERROR: could not restart the monitor daemon" >&2 + fi +} + +case "$MODE" in + restore) + restore "${3:-}" + exit $? + ;; + run) ;; + *) echo "usage: $0 run|restore ..." >&2; exit 2 ;; +esac + +MON="${3:-0}" +TMPD="${4:-}" +trap '[ -z "$TMPD" ] || rm -rf "$TMPD"' EXIT + +set_state installing || { echo "ERROR: cannot write $BK/state" >&2; release_marker; exit 1; } +URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}" +echo "install: $URL" +irc=0 +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$URL" | bash -s -- "$WS" || irc=$? +elif command -v wget >/dev/null 2>&1; then + wget -qO- "$URL" | bash -s -- "$WS" || irc=$? +else + echo "ERROR: neither curl nor wget is available to fetch the installer" >&2 + irc=1 +fi + +if [ "$irc" -ne 0 ]; then + echo "ERROR: the install failed (rc $irc); rolling back" >&2 + if restore; then + rm -rf "$BK" + echo "rolled back: the workspace is at the version it had before the update" + restart_old_monitor "$MON" + else + echo "ERROR: the rollback failed; the backup is kept at $BK (the next update restores it)" >&2 + fi + release_marker + exit 1 +fi +set_state installed + +POST="$WS/.agents/skills/multi-agent-mux-update/scripts/post_update.sh" +prc=0 +if [ -f "$POST" ]; then + bash "$POST" --monitor "$MON" || prc=$? +else + echo "ERROR: the installed version has no $POST; the registry converts on its next write and the monitor was not restarted" >&2 + prc=1 +fi +rm -rf "$BK" +release_marker +[ "$prc" -eq 0 ] && echo "Update complete." +exit "$prc" diff --git a/.agents/skills/multi-agent-mux-update/scripts/post_update.sh b/.agents/skills/multi-agent-mux-update/scripts/post_update.sh new file mode 100755 index 0000000..9330952 --- /dev/null +++ b/.agents/skills/multi-agent-mux-update/scripts/post_update.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# post_update.sh — multi-agent-mux-update phase 2 (Issue #72, decision 5). +# post_update.sh --monitor <0|1> +# +# Run by install_phase.sh from the NEWLY installed tree, in its own process: +# converts the registry with the new code's entry point (lib_py.registry_format, +# #71) and starts the monitor again if it ran before the update. Exit 0, or +# nonzero when the conversion or the monitor restart failed (the install is +# kept either way: the registry converts again on its next write). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)" +export WORKSPACE_ROOT="$WS" +export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml" +# shellcheck source=../../lib.sh +source "$WS/.agents/skills/lib.sh" + +MON=0 +while [ $# -gt 0 ]; do + case "$1" in + --monitor) MON="$2"; shift 2 ;; + *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; + esac +done + +rc=0 +if [ ! -f "$AGENT_SESSIONS_YAML" ] && [ ! -f "$WS/.mam/agent-sessions.db" ]; then + echo "registry: nothing to convert (no registry)" +else + "$(_delegate_py_bin)" -m lib_py.registry_format --yaml "$AGENT_SESSIONS_YAML" || rc=$? + if [ "$rc" -ne 0 ]; then + echo "ERROR: the registry was not converted (rc $rc); the install is kept and the next registry write converts it" >&2 + fi +fi + +if [ "$MON" = "1" ]; then + if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then + echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" + elif pid="$(start_watchdog update "$WS")"; then + echo "monitor: restarted (PID $pid)" + else + echo "ERROR: could not restart the monitor daemon" >&2 + rc=1 + fi +fi +exit "$rc" diff --git a/.agents/skills/multi-agent-mux-update/scripts/update.sh b/.agents/skills/multi-agent-mux-update/scripts/update.sh new file mode 100755 index 0000000..a083283 --- /dev/null +++ b/.agents/skills/multi-agent-mux-update/scripts/update.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# update.sh — multi-agent-mux-update (Issue #72): update an installed workspace in place. +# Usage: +# bash .agents/skills/multi-agent-mux-update/scripts/update.sh [-y|--yes] +# +# Phase 1 (this script, the installed code): publish .mam/update-in-progress +# under the admission lock (lib.sh mam_update_admit is the other side), refuse +# while anything live could see the tree change (lib_py/update.py precheck, a +# live loop marker, a monitor.lock held by a monitor the skills did not start), +# stop the monitor daemon, back up what the install can change into +# .mam/update-backup/, then exec a copy of install_phase.sh outside the tree. +# That copy runs the installer (MAM_INSTALLER_URL) in place, rolls back on +# failure, and runs phase 2 (post_update.sh of the NEW tree) on success. +# +# Exit codes: +# 0 = updated (registry converted or nothing to convert) +# 1 = refused or failed; what was changed and restored is printed +# 2 = invalid args +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)" +export WORKSPACE_ROOT="$WS" +export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml" +# shellcheck source=../../lib.sh +source "$WS/.agents/skills/lib.sh" +# shellcheck source=../../multi-agent-mux-loop/scripts/loop_lock.sh +source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" + +YES=0 +while [ $# -gt 0 ]; do + case "$1" in + -y|--yes) YES=1; shift ;; + -h|--help) sed -n '2,4p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; + esac +done + +MAM="$WS/.mam" +MARKER="$MAM/update-in-progress" +BK="$MAM/update-backup" +PY="$(_delegate_py_bin)" + +if [ ! -f "$WS/.mam_deploy/remove.sh" ] && [ ! -f "$WS/remove.sh" ]; then + echo "ERROR: $WS is not an installed MAM workspace (no .mam_deploy/remove.sh); a git checkout updates with git pull" >&2 + exit 1 +fi + +if [ "$YES" != "1" ]; then + if [ ! -t 0 ]; then + echo "ERROR: not a terminal; run with -y/--yes" >&2 + exit 1 + fi + ans="" + read -r -p "Update MAM in $WS to the latest version? [y/N]: " ans || ans="" + case "$ans" in + y|Y|yes|Yes|YES) ;; + *) echo "Update cancelled."; exit 0 ;; + esac +fi + +# --- the marker, published under the exclusive admission lock (decision 8) --- +mkdir -p "$MAM" +exec 7>>"$MAM/update-admission.lock" +case "$("$PY" -m lib_py.pane_lock acquire --fd 7 --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in + locked) ;; + timeout) + echo "ERROR: a create, resume, loop, delegate submit, stop or orc-onboard kept the admission lock past MAM_UPDATE_ADMISSION_WAIT_S; nothing was changed, try again" >&2 + exit 1 ;; + *) + echo "ERROR: the admission lock $MAM/update-admission.lock could not be decided (lib_py.pane_lock); nothing was changed" >&2 + exit 1 ;; +esac +if [ -f "$MARKER" ] && [ "$(_mam_lock_holder_state "$MARKER")" = "stale" ]; then + echo "NOTE: stale update marker (pid $(_mam_lock_field "$MARKER" pid)) taken over; its update is gone" +fi +marker_rc=0 +mam_acquire_loop_lock "$MARKER" || marker_rc=$? +exec 7>&- +case "$marker_rc" in + 0) ;; + 3) echo "ERROR: another update is in progress (pid $(_mam_lock_field "$MARKER" pid)); nothing was changed" >&2; exit 1 ;; + *) echo "ERROR: cannot tell whether another update is in progress (ps unavailable?); nothing was changed" >&2; exit 1 ;; +esac + +TMPD="" +MONITOR_WAS_RUNNING=0 +RESTART_MONITOR=0 +_phase1_exit() { + local rc=$? + if [ "$RESTART_MONITOR" = "1" ]; then + _restart_monitor || true + fi + mam_release_loop_lock "$MARKER" || true + [ -z "$TMPD" ] || rm -rf "$TMPD" + exit "$rc" +} +trap _phase1_exit EXIT + +_restart_monitor() { + if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then + echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" + return 0 + fi + local pid + pid=$(start_watchdog update "$WS") || { echo "ERROR: could not restart the monitor daemon" >&2; return 1; } + echo "monitor: restarted (PID $pid)" +} + +_monitor_json() { + "$PY" -m lib_py.update monitor --workspace "$WS" +} +_json_field() { + "$PY" -c 'import json,sys; v=json.load(sys.stdin)[sys.argv[1]]; print(" ".join(v) if isinstance(v, list) else v)' "$1" +} + +# --- pre-check: refuse before any change (decisions 1, 7; U3) --- +BLOCKERS="" +_block() { BLOCKERS="${BLOCKERS} - $1 +"; } + +if [ -f "$MAM/loop-guard-active" ]; then + loop_st="$(_mam_lock_holder_state "$MAM/loop-guard-active")" + case "$loop_st" in + stale) echo "NOTE: stale loop marker $MAM/loop-guard-active (its loop is gone); not a reason to refuse" ;; + *) _block "a review loop is running (pid $(_mam_lock_field "$MAM/loop-guard-active" pid), holder $loop_st); wait for it to end" ;; + esac +fi + +STATE_JSON="" +if ! STATE_JSON="$(load_state_json strict)"; then + _block "the registry $AGENT_SESSIONS_YAML cannot be read; fix it first" + STATE_JSON='{}' +fi +REAL_HERDR="$(_resolve_real_herdr_path || true)" +if [ -z "$REAL_HERDR" ]; then + _block "no herdr binary found on PATH, so the live agents cannot be listed" +else + pre_out="" + pre_rc=0 + pre_out="$(MAM_STATE_JSON="$STATE_JSON" "$PY" -m lib_py.update precheck --workspace "$WS" --herdr "$REAL_HERDR")" || pre_rc=$? + if [ "$pre_rc" -ne 0 ]; then + while IFS= read -r line; do + [ -n "$line" ] && _block "$line" + done <<< "${pre_out:-lib_py.update precheck failed (rc $pre_rc)}" + fi +fi + +MON_JSON="$(_monitor_json)" || { _block "cannot inspect the monitor daemon (lib_py.update monitor)"; MON_JSON='{"pids": [], "lock": "free"}'; } +MON_PIDS="$(printf '%s' "$MON_JSON" | _json_field pids)" +MON_LOCK="$(printf '%s' "$MON_JSON" | _json_field lock)" +if [ -z "$MON_PIDS" ] && [ "$MON_LOCK" = "held" ]; then + _block "$MAM/monitor.lock is held by a monitor the skills did not start (not 'bash $WS/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --subscribe'); stop it (find it with: pgrep -af reconcile.sh) and try again" +fi + +if [ -n "$BLOCKERS" ]; then + echo "ERROR: update refused; nothing was changed:" >&2 + printf '%s' "$BLOCKERS" >&2 + exit 1 +fi + +# --- stop the monitor daemon (decision 2) --- +_monitor_gone() { + local p + for p in "$@"; do + kill -0 "$p" 2>/dev/null && return 1 + done + [ "$(_monitor_json | _json_field lock)" = "free" ] +} +_wait_monitor_gone() { + local deadline=$(( $(date +%s) + $1 )) + shift + while :; do + _monitor_gone "$@" && return 0 + [ "$(date +%s)" -lt "$deadline" ] || return 1 + sleep 0.5 + done +} +if [ -n "$MON_PIDS" ]; then + MONITOR_WAS_RUNNING=1 + # shellcheck disable=SC2086 + set -- $MON_PIDS + kids="" + for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done + echo "monitor: stopping the daemon (PID $MON_PIDS)" + kill -TERM "$@" 2>/dev/null || true + if ! _wait_monitor_gone "$(mam_int_knob MAM_UPDATE_MONITOR_STOP_S 30 1)" "$@"; then + for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done + echo "WARNING: the monitor did not stop within MAM_UPDATE_MONITOR_STOP_S; sending SIGKILL to $* $kids" >&2 + # shellcheck disable=SC2086 + kill -KILL "$@" $kids 2>/dev/null || true + # shellcheck disable=SC2086 + if _wait_monitor_gone 5 "$@" $kids; then + echo "ERROR: the monitor did not stop in time; it was killed and is started again. Nothing else was changed." >&2 + _restart_monitor || true + exit 1 + fi + echo "ERROR: could not restore the monitor state: PIDs still alive or monitor.lock still held ($*$kids; lock $(_monitor_json | _json_field lock)). Nothing else was changed." >&2 + exit 1 + fi + RESTART_MONITOR=1 +fi + +TMPD="$(mktemp -d "${TMPDIR:-/tmp}/mam-update.XXXXXX")" +cp "$SCRIPT_DIR/install_phase.sh" "$TMPD/install_phase.sh" + +# --- a leftover backup of an update that died (U4) --- +if [ -e "$BK" ]; then + left_st="$(cat "$BK/state" 2>/dev/null || true)" + case "$left_st" in + ""|backing-up|installed) + echo "NOTE: removing the backup a previous update left (state ${left_st:-none}); nothing to restore" + rm -rf "$BK" ;; + installing) + echo "NOTE: a previous update died during its install; restoring its backup (the registry is left as it is)" + if ! bash "$TMPD/install_phase.sh" restore "$WS" --skip-registry; then + RESTART_MONITOR=0 + echo "ERROR: restoring the previous update's backup failed; it is kept at $BK. Nothing more was changed; the monitor stays stopped." >&2 + exit 1 + fi + rm -rf "$BK" + echo "the previous update was rolled back" ;; + *) + echo "ERROR: $BK has an unknown state '$left_st'; it is kept and nothing was changed. Check it and remove it by hand." >&2 + exit 1 ;; + esac +fi + +# --- the rollback backup (decisions 3, 10; U4) --- +_set_state() { printf '%s\n' "$1" > "$BK/state.tmp" && mv -f "$BK/state.tmp" "$BK/state"; } +ITEMS=".agents .mam_deploy AGENTS.md MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md .mam.env.example .mam.env .gitignore remove.sh update.sh .venv .mam/install_manifest.txt .mam/install_state .mam/asset_hashes.txt .mam/version.txt .mam/base .mam/skill-backups" +_backup() { + mkdir -p "$BK/files" || return 1 + _set_state backing-up || return 1 + : > "$BK/items" && : > "$BK/absent" || return 1 + local rel + for rel in $ITEMS; do + printf '%s\n' "$rel" >> "$BK/items" || return 1 + if [ -e "$WS/$rel" ] || [ -L "$WS/$rel" ]; then + mkdir -p "$BK/files/$(dirname "$rel")" && cp -pPR "$WS/$rel" "$BK/files/$rel" || return 1 + else + printf '%s\n' "$rel" >> "$BK/absent" || return 1 + fi + done + "$PY" -m lib_py.update backup-registry --workspace "$WS" --dest "$BK/registry" >/dev/null +} +echo "backup: $BK" +if ! _backup; then + rm -rf "$BK" + echo "ERROR: the backup could not be made; nothing was changed" >&2 + exit 1 +fi + +# --- hand over to the copy (decision 5): same pid, so the marker stays live --- +RESTART_MONITOR=0 +trap - EXIT +exec bash "$TMPD/install_phase.sh" run "$WS" "$MONITOR_WAS_RUNNING" "$TMPD" diff --git a/README.ko.md b/README.ko.md index cac49cb..02c00e5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -38,7 +38,7 @@ bash deploy/install.sh - `MQTT_BROKER=localhost` 로 된 초기 `.mam.env` 를 씁니다. 이미 있는 `.mam.env` 는 그대로 둡니다. 예전 `.env` 만 있으면 그것을 `.mam.env` 로 복사하고 원본은 남깁니다. - `.venv`, `.mam/`, `.mam.env` 를 `.gitignore` 에 추가합니다. -`.mam.env` 의 `MQTT_BROKER` 를 직접 운영하는 브로커로 바꾸십시오(사설 브로커는 [MESSAGING.md](MESSAGING.md) 참고). `MQTT_BROKER` 가 아예 설정되지 않으면 코드는 공개 브로커 `broker.hivemq.com` 을 씁니다. 시험용으로는 괜찮지만 실제 작업용은 아닙니다. `deploy/generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만듭니다(`--force` 는 `.mam.env.bak` 을 남기고 덮어씁니다). 업데이트와 제거는 `deploy/update.sh`, `deploy/remove.sh` 입니다. `deploy/install_mam.sh` 는 로컬 클론에서 다른 디렉터리로 복사해 설치하는 별도 설치기입니다. +`.mam.env` 의 `MQTT_BROKER` 를 직접 운영하는 브로커로 바꾸십시오(사설 브로커는 [MESSAGING.md](MESSAGING.md) 참고). `MQTT_BROKER` 가 아예 설정되지 않으면 코드는 공개 브로커 `broker.hivemq.com` 을 씁니다. 시험용으로는 괜찮지만 실제 작업용은 아닙니다. `deploy/generate-env.sh` 는 `.mam.env` 가 없을 때 `.mam.env.example` 로 만듭니다(`--force` 는 `.mam.env.bak` 을 남기고 덮어씁니다). 업데이트는 `multi-agent-mux-update` 스킬, 제거는 `deploy/remove.sh` 입니다. `deploy/install_mam.sh` 는 로컬 클론에서 다른 디렉터리로 복사해 설치하는 별도 설치기입니다. --- @@ -56,6 +56,7 @@ bash deploy/install.sh | **`multi-agent-mux-delegate-job`** | 세션(`herdr:`)이나 다른 호스트의 러너(`remote:`)에 잡을 위임하고 결과를 기다립니다. [위임 계약](#-위임-계약)을 보십시오. | | **`multi-agent-mux-loop`** | 계획·구현·리뷰: 선택적인 planner 와 creator, 그다음 선택한 리뷰어(`--reviewer A,B`, 또는 `--all-reviewer` 로 creator 를 뺀 실행 중인 리뷰어 전원)가 모두 `PASS` 를 낼 때까지(또는 `--max-loop` 까지). 둘 다 없으면 creator 가 스스로 리뷰합니다. 루프 밖에서 끝난 작업은 `--review-only` 로 리뷰합니다. 통과한 리포트는 `.agents/reports//` 으로 옮겨 보관합니다. | | **`multi-agent-mux-orc-onboard`** | 오케스트레이터 자신의 세션 ID 를 등록해, 세션 탐색이 그것을 하위 에이전트로 착각하지 않게 합니다. | +| **`multi-agent-mux-update`** | 설치된 작업 공간을 제자리에서 업데이트합니다. 실행 중인 세션·열린 잡·루프가 있으면 거부하고, 모니터를 멈췄다가 원래대로 되돌리며, 설치가 실패하면 되돌리고, 새 코드로 레지스트리를 변환합니다. | --- @@ -195,7 +196,7 @@ sequenceDiagram │ │ └── *.py # pane_lock, stall_watch, worktree, herdr_config, ... │ └── multi-agent-mux-*/ # 스킬마다 디렉터리 하나 (SKILL.md + scripts/) ├── .mam/ # 런타임 상태 (git 제외): 세션, 잡, 감사 로그, shim -├── deploy/ # install.sh, install_mam.sh, update.sh, remove.sh, generate-env.sh +├── deploy/ # install.sh, install_mam.sh, remove.sh, generate-env.sh ├── docs/ # contracts/, design/, 에이전트 통합·브리지 가이드 ├── nats-docker/ # 사설 NATS 브로커 배포 (서브모듈) ├── tests/ # pytest 스위트 diff --git a/README.md b/README.md index e185f01..38c789e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ bash deploy/install.sh - writes a starter `.mam.env` with `MQTT_BROKER=localhost`. An existing `.mam.env` is kept. If only a legacy `.env` exists, it is copied to `.mam.env` and the original stays; - adds `.venv`, `.mam/` and `.mam.env` to `.gitignore`. -Point `MQTT_BROKER` in `.mam.env` at a broker you run (see [MESSAGING.md](MESSAGING.md) for a private broker). If `MQTT_BROKER` is not set at all, the code falls back to the public `broker.hivemq.com`. That is fine for a trial, but not for real work. `deploy/generate-env.sh` creates `.mam.env` from `.mam.env.example` when it is missing (`--force` overwrites it, keeping a `.mam.env.bak`). `deploy/update.sh` and `deploy/remove.sh` update and uninstall. `deploy/install_mam.sh` is a separate installer that copies from a local clone into another directory. +Point `MQTT_BROKER` in `.mam.env` at a broker you run (see [MESSAGING.md](MESSAGING.md) for a private broker). If `MQTT_BROKER` is not set at all, the code falls back to the public `broker.hivemq.com`. That is fine for a trial, but not for real work. `deploy/generate-env.sh` creates `.mam.env` from `.mam.env.example` when it is missing (`--force` overwrites it, keeping a `.mam.env.bak`). The `multi-agent-mux-update` skill updates an installed workspace, and `deploy/remove.sh` uninstalls. `deploy/install_mam.sh` is a separate installer that copies from a local clone into another directory. --- @@ -56,6 +56,7 @@ Every skill lives under `.agents/skills/` with a `SKILL.md` that documents its o | **`multi-agent-mux-delegate-job`** | Delegates a job to a session (`herdr:`) or to a runner on another host (`remote:`) and waits for its result. See [Delegation contract](#-delegation-contract). | | **`multi-agent-mux-loop`** | Plan, implement, review: an optional planner and a creator, then the selected reviewers (`--reviewer A,B`, or `--all-reviewer` for every running reviewer except the creator) until all of them `PASS` or `--max-loop` is reached. With neither option the creator reviews its own work. `--review-only` reviews work finished outside the loop. Passing reports are promoted to `.agents/reports//`. | | **`multi-agent-mux-orc-onboard`** | Registers the orchestrator's own session id, so session discovery never mistakes it for a sub-agent. | +| **`multi-agent-mux-update`** | Updates an installed workspace in place: refuses while sessions, open jobs or a loop are live; stops and restores the monitor; rolls back on a failed install; converts the registry with the new code. | --- @@ -195,7 +196,7 @@ sequenceDiagram │ │ └── *.py # pane_lock, stall_watch, worktree, herdr_config, ... │ └── multi-agent-mux-*/ # One directory per skill (SKILL.md + scripts/) ├── .mam/ # Runtime state (git-ignored): sessions, jobs, audit logs, shim -├── deploy/ # install.sh, install_mam.sh, update.sh, remove.sh, generate-env.sh +├── deploy/ # install.sh, install_mam.sh, remove.sh, generate-env.sh ├── docs/ # contracts/, design/, agent integration and bridge guides ├── nats-docker/ # Private NATS broker deployment (submodule) ├── tests/ # pytest suite diff --git a/deploy/README.md b/deploy/README.md index 08aa663..3a57588 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -9,7 +9,7 @@ This directory contains packaging templates and installation scripts to deploy t * **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), sets up a local python virtual environment (`.venv`), and performs 3-way refresh with key-level registry merge (Rev.2). * **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, manifests, and asset hashes into target projects. * **`lib_ownership.sh`**: Single source of truth for framework-owned files and key-level registry files. -* **`update.sh`**: In-place updater script installed into target `.mam_deploy/update.sh`. +* **Updates**: installed workspaces update with the `multi-agent-mux-update` skill (`bash .agents/skills/multi-agent-mux-update/scripts/update.sh`). * **`remove.sh`**: Clean uninstaller script installed into target `.mam_deploy/remove.sh`. * **`generate-env.sh`**: Environment configuration bootstrap helper. * **`INSTALL.md`**: Detailed installation and quick-start user manual. @@ -40,7 +40,7 @@ bash deploy/install_mam.sh --target /path/to/your/project Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows. > [!NOTE] -> `install_mam.sh` automatically deploys `.mam_deploy/update.sh` and `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls. +> `install_mam.sh` automatically deploys `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls. ### 3. Custom Fork / Private Mirror Installations If you run a private mirror or fork, you can override the source URLs during installation using environment variables: @@ -53,7 +53,7 @@ curl -fsSL https://my-mirror.example.com/.../install.sh \ bash # Updating an existing workspace against a mirror -MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash deploy/update.sh +MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash .agents/skills/multi-agent-mux-update/scripts/update.sh ``` ### 4. Registering as a Workspace Plugin diff --git a/deploy/gitea-ci.yml b/deploy/gitea-ci.yml index 9314530..1e33c70 100644 --- a/deploy/gitea-ci.yml +++ b/deploy/gitea-ci.yml @@ -41,7 +41,7 @@ jobs: shellcheck deploy/install.sh shellcheck deploy/install_mam.sh shellcheck deploy/generate-env.sh - shellcheck deploy/update.sh + shellcheck .agents/skills/multi-agent-mux-update/scripts/*.sh shellcheck deploy/remove.sh echo "✅ ShellCheck completed successfully." diff --git a/deploy/install.sh b/deploy/install.sh index 2a63d2b..488ac58 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -125,7 +125,9 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre FETCH_METHOD="archive" if [ -d "$REPO_URL" ]; then echo "🌐 Copying local working tree into a staging area..." - cp -R "$REPO_URL/." "$STAGE_DIR/" + # Everything but .mam/: runtime state is never staged, and it may hold the + # update skill's rollback copy (.mam/update-backup/, Issue #72). + find "$REPO_URL/." -mindepth 1 -maxdepth 1 ! -name .mam -exec cp -R {} "$STAGE_DIR/" \; FETCH_METHOD="local" elif command -v git &>/dev/null; then echo "🌐 Cloning repository (shallow) into a staging area..." @@ -157,9 +159,15 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre MANIFEST_FILE=".mam/install_manifest.txt" touch "$MANIFEST_FILE" - # Migrate legacy root layout for remove.sh and update.sh into .mam_deploy/ if owned + # Drop every line naming this path from the manifest + manifest_drop() { + grep -Fvx -- "$1" "$MANIFEST_FILE" > "$MANIFEST_FILE.tmp" || true + mv -f "$MANIFEST_FILE.tmp" "$MANIFEST_FILE" + } + + # Migrate legacy root layout for remove.sh into .mam_deploy/ if owned mkdir -p .mam_deploy - for legacy_script in remove.sh update.sh; do + for legacy_script in remove.sh; do if [ -f "$legacy_script" ] && grep -Fqx "$legacy_script" "$MANIFEST_FILE" 2>/dev/null; then mv -f "$legacy_script" ".mam_deploy/$legacy_script" python3 -c ' @@ -179,6 +187,13 @@ with open(path, "w") as f: fi done + # update.sh is no longer shipped (Issue #72; the multi-agent-mux-update skill + # updates). A legacy root update.sh goes only if the manifest says we put it there. + if [ -f update.sh ] && grep -Fqx update.sh "$MANIFEST_FILE" 2>/dev/null; then + rm -f update.sh + manifest_drop update.sh + fi + # Safe refresh & fingerprint checking logic TS=$(date -u +%Y%m%dT%H%M%SZ) FRAMEWORK_LEDGER=".mam/.framework_ledger.tmp" @@ -374,7 +389,7 @@ MERGEREG fi done - # Install remove.sh and update.sh into .mam_deploy/ + # Install remove.sh into .mam_deploy/ mkdir -p .mam_deploy if [ -f "$STAGE_DIR/deploy/remove.sh" ]; then cp "$STAGE_DIR/deploy/remove.sh" .mam_deploy/remove.sh @@ -384,13 +399,9 @@ MERGEREG fi fi - if [ -f "$STAGE_DIR/deploy/update.sh" ]; then - cp "$STAGE_DIR/deploy/update.sh" .mam_deploy/update.sh - chmod 0755 .mam_deploy/update.sh - if ! grep -Fqx ".mam_deploy/update.sh" "$MANIFEST_FILE" 2>/dev/null; then - echo ".mam_deploy/update.sh" >> "$MANIFEST_FILE" - fi - fi + # An older install's updater is not shipped any more (Issue #72) + rm -f .mam_deploy/update.sh + manifest_drop .mam_deploy/update.sh if [ -f "$STAGE_DIR/.mam.env.example" ] && [ ! -e ".mam.env.example" ]; then cp "$STAGE_DIR/.mam.env.example" . @@ -407,6 +418,34 @@ MERGEREG fi fi + # Framework-owned files the previous install recorded (manifest and + # asset_hashes.txt) that this version no longer ships (Issue #72). Unmodified + # ones are deleted. Modified ones are kept and become the user's file: they + # leave the manifest, so remove.sh leaves them alone, and asset_hashes.txt + # (rebuilt below from the shipped files only). + if [ -f .mam/asset_hashes.txt ]; then + SHIPPED_LIST=".mam/.shipped_paths.tmp" + cut -f2 "$FRAMEWORK_LEDGER" > "$SHIPPED_LIST" + while read -r _old_sha _old_path; do + [ -n "${_old_path:-}" ] || continue + is_framework_owned "$_old_path" || continue + grep -Fqx -- "$_old_path" "$MANIFEST_FILE" || continue + if grep -Fqx -- "$_old_path" "$SHIPPED_LIST"; then continue; fi + if [ -f "$_old_path" ] && [ "$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$_old_path")" != "$_old_sha" ]; then + echo "ℹ️ Local modification detected (no longer shipped, kept as yours): $_old_path" >&2 + else + rm -f "$_old_path" + _old_dir="$(dirname "$_old_path")" + while [ "$_old_dir" != ".agents" ] && rmdir "$_old_dir" 2>/dev/null; do + _old_dir="$(dirname "$_old_dir")" + done + fi + rm -f ".mam/base/$_old_path" + manifest_drop "$_old_path" + done < .mam/asset_hashes.txt + rm -f "$SHIPPED_LIST" + fi + # Re-build asset_hashes.txt from the UPSTREAM copy of every framework-owned file python3 - .mam/asset_hashes.txt "$FRAMEWORK_LEDGER" <<'HASHDB' import os, hashlib, sys @@ -590,15 +629,25 @@ with open(gi_path, "w") as f: PY if [ "${MAM_SKIP_GITIGNORE:-0}" -eq 0 ]; then + # Set the key: overwrite it in place, one line per key, other keys kept python3 -c ' import os +os.makedirs(".mam", exist_ok=True) +lines = [] if os.path.exists(".mam/install_state"): - with open(".mam/install_state", "a") as f: - f.write("gitignore_created=1\n") -else: - os.makedirs(".mam", exist_ok=True) - with open(".mam/install_state", "w") as f: - f.write("gitignore_created=1\n") + with open(".mam/install_state") as f: + lines = f.read().splitlines() +out, done = [], False +for line in lines: + if line.startswith("gitignore_created="): + if done: + continue + line, done = "gitignore_created=1", True + out.append(line) +if not done: + out.append("gitignore_created=1") +with open(".mam/install_state", "w") as f: + f.write("\n".join(out) + "\n") ' fi @@ -617,6 +666,6 @@ cat < "$MANIFEST_FILE" ( cd "$TARGET_DIR" && find .agents -type f -print ) >> "$MANIFEST_FILE" -for extra in ".mam.env.example" ".mam_deploy/remove.sh" ".mam_deploy/update.sh" \ +for extra in ".mam.env.example" ".mam_deploy/remove.sh" \ "scripts/generate-env.sh"; do if [ -e "$TARGET_DIR/$extra" ]; then echo "$extra" >> "$MANIFEST_FILE" diff --git a/deploy/remove.sh b/deploy/remove.sh index 08b09a6..de79ed8 100644 --- a/deploy/remove.sh +++ b/deploy/remove.sh @@ -92,6 +92,7 @@ else ".agents/skills/multi-agent-mux-resume" ".agents/skills/multi-agent-mux-status" ".agents/skills/multi-agent-mux-stop" + ".agents/skills/multi-agent-mux-update" ".agents/hooks" ".agents/hooks.json" ".agents/MULTI_AGENT_RULES.md" diff --git a/deploy/update.sh b/deploy/update.sh deleted file mode 100644 index 48b1836..0000000 --- a/deploy/update.sh +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================== -# update.sh — Multi-Agent Mux (MAM) Orchestration Updater -# ============================================================================== -# Safely updates MAM skills, virtual environment, and docs to the latest version. -# Preserves user configuration (.env) and local metadata/jobs database (.mam). -# ============================================================================== -set -euo pipefail - -TARGET_DIR="" -FORCE=0 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - -y|--yes|--force) - FORCE=1 - shift - ;; - *) - TARGET_DIR="$1" - shift - ;; - esac -done - -if [ -z "$TARGET_DIR" ]; then - if [ "$(basename "$SCRIPT_DIR")" = ".mam_deploy" ]; then - TARGET_DIR="$(dirname "$SCRIPT_DIR")" - else - TARGET_DIR="$(pwd)" - fi -else - if [ "$(basename "$TARGET_DIR")" = ".mam_deploy" ]; then - TARGET_DIR="$(dirname "$TARGET_DIR")" - fi - if [ ! -d "$TARGET_DIR" ]; then - echo "❌ Error: Target directory '$TARGET_DIR' does not exist." >&2 - exit 1 - fi - TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" -fi - -echo "====================================================================" -echo "⚡ Starting Multi-Agent Mux (MAM) Update" -echo "📂 Target Workspace: $TARGET_DIR" -echo "====================================================================" - -cd "$TARGET_DIR" - -# 1. Verification of existing install (B-2: dual resolution) -REMOVER="" -for cand in ".mam_deploy/remove.sh" "remove.sh"; do - if [ -f "$cand" ]; then - REMOVER="$cand" - break - fi -done - -if [ -z "$REMOVER" ]; then - echo "❌ Error: No MAM installation (remove.sh) found in '$TARGET_DIR'." >&2 - echo " Please run install.sh first to set up the workspace." >&2 - exit 1 -fi - -# Request confirmation if not forced -if [ $FORCE -eq 0 ]; then - echo "⚠️ WARNING: This will update MAM orchestration skills, virtual environment, " - echo " and docs to the latest version." - echo " (Your configuration, job history, and custom skills will be preserved)." - if [ ! -t 0 ]; then - echo "❌ Error: Non-interactive terminal detected. Please run with -y/--yes/--force." >&2 - exit 1 - fi - if ! read -p "❓ Proceed with update? [y/N]: " -r response; then - response="n" - fi - if [[ ! "$response" =~ ^[yY](es)?$ ]]; then - echo "❌ Update cancelled by user." - exit 0 - fi -fi - -# 1.5 Pre-capture legacy ownership before remove.sh deletes the manifest (M-4) -MAM_LEGACY_ENV_OWNED=0 -if [ -f ".mam/install_manifest.txt" ] && grep -Fqx ".env" ".mam/install_manifest.txt" 2>/dev/null; then - MAM_LEGACY_ENV_OWNED=1 -fi -export MAM_LEGACY_ENV_OWNED - -# 2. Stage backups of user configurations and metadata to prevent deletion -echo "💾 Backing up configuration and database..." -HAS_ENV=0 -ENV_BACKUP_SRC="" -ENV_BACKUP_TMP="" - -if [ -f ".mam.env" ]; then - HAS_ENV=1 - ENV_BACKUP_SRC=".mam.env" - ENV_BACKUP_TMP=".mam.env.update-tmp" - mv ".mam.env" "$ENV_BACKUP_TMP" -elif [ -f ".env" ]; then - HAS_ENV=1 - ENV_BACKUP_SRC=".env" - ENV_BACKUP_TMP=".env.update-tmp" - mv ".env" "$ENV_BACKUP_TMP" -fi - -HAS_MAM=0 -if [ -d ".mam" ]; then - HAS_MAM=1 - # Copy database and jobs to temporary backup outside of .mam. - # We do NOT move the .mam folder away so that remove.sh can still read .mam/install_manifest.txt! - mkdir -p .mam.update-tmp - # Copy SQLite databases and session files - for db in .mam/agent-sessions.*; do - if [ -f "$db" ]; then - cp -f "$db" .mam.update-tmp/ - fi - done - # Copy jobs history - if [ -d ".mam/jobs" ] && [ "$(ls -A .mam/jobs 2>/dev/null)" ]; then - mkdir -p .mam.update-tmp/jobs - cp -rf .mam/jobs/* .mam.update-tmp/jobs/ - fi - # Copy delegate logs - if [ -d ".mam/delegate_job_logs" ] && [ "$(ls -A .mam/delegate_job_logs 2>/dev/null)" ]; then - mkdir -p .mam.update-tmp/delegate_job_logs - cp -rf .mam/delegate_job_logs/* .mam.update-tmp/delegate_job_logs/ - fi - # Copy manifest so we have a backup - if [ -f ".mam/install_manifest.txt" ]; then - cp -f .mam/install_manifest.txt .mam.update-tmp/ - fi - # C12: Copy MAM state files across update cycle - for st in install_state asset_hashes.txt version.txt; do - if [ -f ".mam/$st" ]; then - cp -f ".mam/$st" .mam.update-tmp/ - fi - done - if [ -d ".mam/skill-backups" ]; then - mkdir -p .mam.update-tmp/skill-backups - cp -rf .mam/skill-backups/* .mam.update-tmp/skill-backups/ 2>/dev/null || true - fi -fi - -# Define trap to restore backup files on failure -restore_on_failure() { - echo "❌ Update failed. Reverting configuration and database to previous state..." - if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then - mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC" 2>/dev/null || true - fi - if [ $HAS_MAM -eq 1 ] && [ -d ".mam.update-tmp" ]; then - # Revert to old database/jobs backup by restoring .mam directory - rm -rf .mam 2>/dev/null || true - mkdir -p .mam - cp -f .mam.update-tmp/agent-sessions.* .mam/ 2>/dev/null || true - if [ -d ".mam.update-tmp/jobs" ]; then - cp -rf .mam.update-tmp/jobs .mam/ 2>/dev/null || true - fi - if [ -d ".mam.update-tmp/delegate_job_logs" ]; then - cp -rf .mam.update-tmp/delegate_job_logs .mam/ 2>/dev/null || true - fi - if [ -f ".mam.update-tmp/install_manifest.txt" ]; then - cp -f .mam.update-tmp/install_manifest.txt .mam/ 2>/dev/null || true - fi - for st in install_state asset_hashes.txt version.txt; do - if [ -f ".mam.update-tmp/$st" ]; then - cp -f ".mam.update-tmp/$st" .mam/ 2>/dev/null || true - fi - done - if [ -d ".mam.update-tmp/skill-backups" ]; then - mkdir -p .mam/skill-backups - cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true - fi - rm -rf .mam.update-tmp 2>/dev/null || true - fi -} -trap restore_on_failure EXIT - -# 3. Perform uninstallation of existing files -echo "🗑️ Removing existing installation..." -# remove.sh will run in manifest mode because .mam/install_manifest.txt is still present. -# It will delete .agents/, documents, scripts, .venv, and .mam folder. -bash "$REMOVER" --force "$TARGET_DIR" - -# 4. Fetch and run the latest installer from Gitea -echo "📥 Fetching and running the latest installer..." -# Note: MAM_*_URL environment variables are automatically inherited by the child installer bash process. -INSTALLER_URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}" -if command -v curl &>/dev/null; then - curl -fsSL "$INSTALLER_URL" | bash -s -- "$TARGET_DIR" -elif command -v wget &>/dev/null; then - wget -qO- "$INSTALLER_URL" | bash -s -- "$TARGET_DIR" -else - echo "❌ Error: Neither 'curl' nor 'wget' is available to fetch the installer." >&2 - exit 1 -fi - -# Disable failure trap since installation succeeded -trap - EXIT - -# 5. Restore backups of configuration and database -echo "🔄 Restoring configuration and database..." -if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then - if [ "$MAM_LEGACY_ENV_OWNED" = "1" ] && [ "$ENV_BACKUP_SRC" = ".env" ] && [ ! -f ".mam.env" ]; then - mv -f "$ENV_BACKUP_TMP" ".mam.env" - chmod 0600 ".mam.env" 2>/dev/null || true - else - mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC" - fi -fi - -if [ $HAS_MAM -eq 1 ]; then - if [ -d ".mam.update-tmp" ]; then - # The installer created a new .mam directory with a new manifest. - # We want to merge the old .mam database/jobs back while keeping the new manifest. - for db in .mam.update-tmp/agent-sessions.*; do - if [ -f "$db" ]; then - cp -f "$db" .mam/ - fi - done - if [ -d ".mam.update-tmp/jobs" ] && [ "$(ls -A .mam.update-tmp/jobs 2>/dev/null)" ]; then - mkdir -p .mam/jobs - cp -rf .mam.update-tmp/jobs/* .mam/jobs/ - fi - if [ -d ".mam.update-tmp/delegate_job_logs" ] && [ "$(ls -A .mam.update-tmp/delegate_job_logs 2>/dev/null)" ]; then - mkdir -p .mam/delegate_job_logs - cp -rf .mam.update-tmp/delegate_job_logs/* .mam/delegate_job_logs/ - fi - # Issue #8: asset_hashes.txt and version.txt describe the tree the - # installer just wrote, so the pre-update copies must NOT come back here - - # restoring them made version.txt name the previous commit and remove.sh - # treat every updated file as locally modified (spurious backups). Only - # install_state carries history worth keeping: merge in any key the fresh - # install did not write (e.g. gitignore_created from the original install). - # The failure path (restore_on_failure) still puts all three back verbatim. - if [ -f ".mam.update-tmp/install_state" ]; then - touch .mam/install_state - # A fresh install_state without a final newline would glue the first - # merged key onto its last line. - if [ -s .mam/install_state ] && [ -n "$(tail -c1 .mam/install_state)" ]; then - printf '\n' >> .mam/install_state - fi - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in - *=*) ;; - *) continue ;; # not a key=value line - esac - key="${line%%=*}" - [ -n "$key" ] || continue - # exact key match, no regex interpretation of the key - if ! awk -F= -v k="$key" '$1 == k { found = 1 } END { exit !found }' .mam/install_state; then - printf '%s\n' "$line" >> .mam/install_state - fi - done < .mam.update-tmp/install_state - fi - if [ -d ".mam.update-tmp/skill-backups" ]; then - mkdir -p .mam/skill-backups - cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true - fi - rm -rf ".mam.update-tmp" - fi -fi - -echo "====================================================================" -echo "🎉 Update complete!" -echo "====================================================================" diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index 2e321dd..b18ff47 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -463,6 +463,20 @@ "evidence": [], "mismatch": null, "note": "post-stop checks; no script offers them as a command - stage 3" + }, + { + "skill": "multi-agent-mux-update", + "start": 17, + "end": 19, + "lang": "bash", + "sha": "63f73ade58d3", + "class": "c", + "kind": "invocation", + "evidence": [ + "multi-agent-mux-update/scripts/update.sh:3" + ], + "mismatch": null, + "note": "entry point call (Issue #72)" } ], "removed": [ diff --git a/tests/test_deploy_layout.py b/tests/test_deploy_layout.py index ad1b288..16efb86 100644 --- a/tests/test_deploy_layout.py +++ b/tests/test_deploy_layout.py @@ -48,7 +48,7 @@ class TestDeployLayout(unittest.TestCase): self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".agents", "skills", "lib.sh"))) def test_td6_td7_td8_mam_deploy_layout_and_removal(self): - """T-D6~D8: Verify remove.sh/update.sh installed in .mam_deploy and work properly.""" + """T-D6~D8: Verify remove.sh installed in .mam_deploy (update.sh not, Issue #72) and works properly.""" subprocess.run( ["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir], env=self.env, @@ -59,7 +59,7 @@ class TestDeployLayout(unittest.TestCase): remover = os.path.join(self.test_dir, ".mam_deploy", "remove.sh") updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh") self.assertTrue(os.path.exists(remover)) - self.assertTrue(os.path.exists(updater)) + self.assertFalse(os.path.exists(updater)) self.assertTrue(os.access(remover, os.X_OK)) # T-D8: Execution from inside .mam_deploy directory targets parent workspace @@ -129,17 +129,29 @@ class TestDeployLayout(unittest.TestCase): self.assertTrue(os.path.exists(backups_dir)) def test_td27_td28_update_preserves_mam_state(self): - """T-D27~D28: Verify update.sh preserves .mam state files across cycles (B-5 gate).""" + """T-D27~D28: Verify the update skill preserves .mam state files across cycles (B-5 gate).""" subprocess.run( ["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir], env=self.env, check=True ) - # Run update.sh - updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh") - res = subprocess.run(["bash", updater, "--force"], cwd=self.test_dir, env=self.env, capture_output=True, text=True) - self.assertEqual(res.returncode, 0, f"update.sh failed: {res.stderr}") + # Run the update skill (Issue #72: .mam_deploy/update.sh is gone) + updater = os.path.join(self.test_dir, ".agents", "skills", "multi-agent-mux-update", "scripts", "update.sh") + env = {k: v for k, v in self.env.items() if not k.startswith("HERDR_") + and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR")} + env["MAM_NO_SUBSCRIBER_DAEMON"] = "1" + # The update lists the live herdr agents; never ask a real server here. + fake = os.path.join(self.temp_dir, "fake-herdr-bin") + os.makedirs(fake) + with open(os.path.join(fake, "herdr"), "w") as f: + f.write('#!/bin/sh\necho \'{"sessions": []}\'\n') + os.chmod(os.path.join(fake, "herdr"), 0o755) + env["PATH"] = fake + ":" + env["PATH"] + res = subprocess.run(["bash", updater, "--yes"], cwd=self.test_dir, env=env, capture_output=True, text=True, + stdin=subprocess.DEVNULL) + self.assertEqual(res.returncode, 0, f"the update skill failed: {res.stdout}{res.stderr}") + self.assertFalse(os.path.exists(os.path.join(self.test_dir, ".mam_deploy", "update.sh"))) # Verify asset_hashes.txt and version.txt still exist self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".mam", "asset_hashes.txt"))) diff --git a/tests/test_issue42_skill_blocks.py b/tests/test_issue42_skill_blocks.py index 92e1a0f..0e58761 100644 --- a/tests/test_issue42_skill_blocks.py +++ b/tests/test_issue42_skill_blocks.py @@ -115,8 +115,8 @@ def test_inventory_matches_skill_md(skill): f"actual (start, end, lang, sha): {json.dumps(actual)}") -def test_inventory_covers_eight_skills(): - assert len(SKILL_NAMES) == 8 +def test_inventory_covers_nine_skills(): + assert len(SKILL_NAMES) == 9 # Issue #72: + multi-agent-mux-update assert {b["skill"] for b in DATA["blocks"]} == set(SKILL_NAMES) diff --git a/tests/test_issue72_installer.py b/tests/test_issue72_installer.py new file mode 100644 index 0000000..46dc20f --- /dev/null +++ b/tests/test_issue72_installer.py @@ -0,0 +1,206 @@ +#!/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")) diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py new file mode 100644 index 0000000..f554bae --- /dev/null +++ b/tests/test_issue72_update.py @@ -0,0 +1,710 @@ +"""Issue #72 - the multi-agent-mux-update skill. + +Every test updates a copy of one workspace installed from this checkout +(MAM_REPO_URL, MAM_SKIP_VENV=1). herdr is a fake that answers `session list +--json` and `--session S agent list` from a JSON spec, so the pre-check's view +of the live agents is set per test; the monitor daemon is a stub reconcile.sh +that holds .mam/monitor.lock like the real one. Nothing here touches a real +herdr server or the control root's monitor. +""" +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import shutil +import signal +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +UPDATE_REL = ".agents/skills/multi-agent-mux-update/scripts/update.sh" +MONITOR_REL = ".agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" + +FAKE_HERDR = r'''#!/usr/bin/env python3 +import json, os, sys +spec = json.load(open(os.environ["FAKE_HERDR_SPEC"])) +a = sys.argv[1:] +server = "default" +if a[:1] == ["--session"]: + server, a = a[1], a[2:] +with open(os.environ["FAKE_HERDR_SPEC"] + ".calls", "a") as f: + f.write(json.dumps([server] + a) + "\n") +if a[:2] == ["session", "list"]: + if spec.get("fail") == "session-list": + sys.exit(1) + print(json.dumps({"sessions": [{"name": n, "running": r} for n, r in spec.get("sessions", {"default": True}).items()]})) +elif a[:2] == ["agent", "list"]: + if spec.get("fail") == "agent-list:" + server: + sys.exit(1) + print(json.dumps({"result": {"agents": spec.get("agents", {}).get(server, [])}})) +''' + +STUB_MONITOR = r'''#!/usr/bin/env bash +# Issue #72 test stub of reconcile.sh: a wrapper whose python child holds monitor.lock. +d="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd -P)" +echo "start $$ $*" >> "$d/.mam/stub-monitor.log" +python3 -c ' +import fcntl, os, signal, sys, time +if os.environ.get("STUB_MONITOR_IGNORE_TERM") == "1": + signal.signal(signal.SIGTERM, signal.SIG_IGN) +f = open(sys.argv[1], "w") +fcntl.flock(f, fcntl.LOCK_EX) +time.sleep(600) +' "$d/.mam/monitor.lock" & +child=$! +if [ "${STUB_MONITOR_IGNORE_TERM:-0}" = "1" ]; then + trap '' TERM +else + trap 'kill $child 2>/dev/null; exit 143' TERM +fi +while kill -0 "$child" 2>/dev/null; do wait "$child"; done +''' + + +def _clean_env(): + env = {k: v for k, v in os.environ.items() + if not k.startswith(("HERDR_", "XDG_", "MAM_", "STUB_")) + and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "DELEGATE_JOB_LOGS_DIR", "PYTHONPATH")} + env.update(MAM_REPO_URL=str(REPO_ROOT), MAM_SKIP_VENV="1", + MAM_INSTALLER_URL="file://" + str(REPO_ROOT / "deploy" / "install.sh"), + MAM_ENV_FILE="/nonexistent/.mam.env", PYTHONDONTWRITEBYTECODE="1") + return env + + +@pytest.fixture(scope="module") +def template(tmp_path_factory): + ws = tmp_path_factory.mktemp("tpl72") / "ws" + ws.mkdir() + res = subprocess.run(["bash", str(REPO_ROOT / "deploy" / "install.sh"), str(ws)], env=_clean_env(), + capture_output=True, text=True, timeout=600) + assert res.returncode == 0, res.stdout + res.stderr + return ws + + +class Box: + def __init__(self, root: Path, ws: Path): + self.root, self.ws = root, ws + self.mam = ws / ".mam" + self.bin = root / "bin" + self.spec = root / "herdr.json" + self.env = _clean_env() + self.env["PATH"] = "%s:%s" % (self.bin, os.environ["PATH"]) + self.env["FAKE_HERDR_SPEC"] = str(self.spec) + self.helpers = [] + self.herdr() + + def herdr(self, sessions=None, agents=None, fail=None): + self.spec.write_text(json.dumps({"sessions": sessions or {"default": True}, + "agents": agents or {}, "fail": fail})) + + def update(self, *args, env=None, timeout=300): + e = dict(self.env, **(env or {})) + return subprocess.run(["bash", str(self.ws / UPDATE_REL), "--yes", *args], cwd=str(self.ws), env=e, + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout) + + def lib(self, script, env=None, timeout=120): + e = dict(self.env, WORKSPACE_ROOT=str(self.ws), **(env or {})) + return subprocess.run(["bash", "-c", 'source "$1/.agents/skills/lib.sh"\n' + script, "_", str(self.ws)], + env=e, capture_output=True, text=True, timeout=timeout) + + def installer(self, body): + p = self.root / "installer.sh" + p.write_text("#!/usr/bin/env bash\nws=\"$1\"\n" + body) + self.env["MAM_INSTALLER_URL"] = "file://" + str(p) + + def helper(self, *argv, **kw): + p = subprocess.Popen(list(argv), start_new_session=True, **kw) + self.helpers.append(p) + return p + + def stub_monitor(self): + p = self.ws / MONITOR_REL + p.write_text(STUB_MONITOR) + + def start_monitor(self, env=None): + """The way create and resume start it: lib.sh start_watchdog (detached, so + not a child of this test that would linger as a zombie).""" + res = self.lib('start_watchdog test "$WORKSPACE_ROOT"', env=dict(env or {})) + assert res.returncode == 0 and res.stdout.strip(), res.stdout + res.stderr + _until(lambda: _lock_held(self.mam / "monitor.lock")) + + def monitor_pids(self): + r = subprocess.run(["pgrep", "-f", "bash %s --subscribe" % (self.ws / MONITOR_REL)], + capture_output=True, text=True) + return r.stdout.split() + + def monitor_starts(self): + p = self.mam / "stub-monitor.log" + return p.read_text().count("start ") if p.exists() else 0 + + +def _until(cond, timeout=15.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + if cond(): + return True + time.sleep(0.1) + raise AssertionError("condition not met within %ss" % timeout) + + +def _lock_held(path: Path) -> bool: + if not path.exists(): + return False + with open(path, "a") as fh: + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fh, fcntl.LOCK_UN) + return False + except BlockingIOError: + return True + + +@pytest.fixture +def box(template, tmp_path): + root = Path(os.path.realpath(tmp_path)) + ws = root / "ws" + shutil.copytree(template, ws, symlinks=True) + (root / "bin").mkdir() + herdr = root / "bin" / "herdr" + herdr.write_text(FAKE_HERDR) + herdr.chmod(0o755) + b = Box(root, ws) + try: + yield b + finally: + for p in b.helpers: + try: + os.killpg(p.pid, signal.SIGKILL) + except OSError: + pass + subprocess.run(["pkill", "-KILL", "-f", str(ws)], capture_output=True) + + +# --- helpers --------------------------------------------------------------------- + +SKIP_SNAPSHOT = ("shim/", "update-admission.lock", "agent-sessions.db.lock", "monitor.lock", "stub-monitor.log") + + +def snapshot(ws: Path) -> dict: + out = {} + for dp, dns, fns in os.walk(ws): + dns[:] = [d for d in dns if d != "__pycache__"] + for f in fns: + p = Path(dp) / f + rel = str(p.relative_to(ws)) + if rel.startswith(".mam/") and any(s in rel for s in SKIP_SNAPSHOT): + continue + out[rel] = "L:" + os.readlink(p) if p.is_symlink() else hashlib.sha256(p.read_bytes()).hexdigest() + return out + + +def write_rows(ws: Path, rows, **top): + d = dict(top, herdr_sessions=rows) + (ws / ".mam" / "agent-sessions.yaml").write_text(yaml.safe_dump(d, sort_keys=False)) + + +def row(name, status="running", server="default", cwd=None, **extra): + r = {"name": name, "status": status, "role": "creator", "herdr_session": server, "herdr_server": server, + "pane": {"cwd": cwd or "/elsewhere", "cmd": "claude"}} + r.update(extra) + return r + + +def write_job(d: Path, jid, status="running", session="herdr:x", **extra): + d.mkdir(parents=True, exist_ok=True) + rec = dict({"job_id": jid, "status": status, "agent_session": session}, **extra) + (d / (jid + ".json")).write_text(json.dumps(rec)) + + +def live_marker(path: Path, box: Box): + """A marker owned by a live process (pid + lstart as loop_lock.sh writes them).""" + p = box.helper("sleep", "120") + ls = subprocess.run(["ps", "-p", str(p.pid), "-o", "lstart="], capture_output=True, text=True).stdout + ls = " ".join(ls.split()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("pid=%d\nlstart=%s\nstarted=x\n" % (p.pid, ls)) + return p + + +def dead_marker(path: Path): + p = subprocess.Popen(["true"]) + p.wait() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("pid=%d\nlstart=Thu Jan 1 00:00:00 1970\nstarted=x\n" % p.pid) + + +def assert_refused_unchanged(box: Box, before: dict, res, *needles): + assert res.returncode == 1, res.stdout + res.stderr + assert "update refused; nothing was changed" in res.stderr, res.stderr + for n in needles: + assert n in res.stderr, (n, res.stderr) + assert snapshot(box.ws) == before + assert not (box.mam / "update-in-progress").exists(), "the refusing update releases its marker" + assert not (box.mam / "update-backup").exists() + + +# --- success and the #8 state rules ------------------------------------------------ + +def test_update_in_place_keeps_mam_and_merges_install_state(box): + mam = box.mam + (mam / "version.txt").write_text("source=old\ncommit=stale0000\nfetched_at=then\nmethod=git\n") + (mam / "herdr_workspace_id").write_text("w9\n") + for d in ("briefs", "diagnostics", "resumelogs", "stoplogs", "jobs", "delegate_job_logs/j1"): + (mam / d).mkdir(parents=True, exist_ok=True) + (mam / d / "keep.txt").write_text(d) + write_job(mam / "jobs", "done1", status="completed") + (mam / "multi-agent-mux-monitor.log").write_text("log\n") + (box.ws / ".mam.env").write_text("MQTT_BROKER=example\n") + with open(mam / "install_state", "a") as fh: + fh.write("custom_marker=kept\n") + write_rows(box.ws, [row("a", status="stopped")], orchestrator_uuids=["u1"]) + keep = {k: v for k, v in snapshot(box.ws).items() + if k.startswith((".mam/herdr_workspace_id", ".mam/briefs", ".mam/diagnostics", ".mam/resumelogs", + ".mam/stoplogs", ".mam/jobs", ".mam/delegate_job_logs", ".mam/multi-agent-mux-monitor.log")) + or k == ".mam.env"} + + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + after = snapshot(box.ws) + assert {k: after.get(k) for k in keep} == keep, "everything that is not the installer's is kept" + assert "stale0000" not in (mam / "version.txt").read_text() + state = (mam / "install_state").read_text().splitlines() + assert "custom_marker=kept" in state and state.count("gitignore_created=1") == 1, state + assert not (box.ws / ".mam_deploy" / "update.sh").exists() + assert not (mam / "update-backup").exists() and not (mam / "update-in-progress").exists() + assert "Update complete." in res.stdout + + +def test_old_format_registry_is_converted_by_the_new_code(box): + old = row("old-one", status="stopped", start_command="HERDR_SESSION_NAME=x herdr new-session", + kill_command="HERDR_SESSION_NAME=x herdr kill-session", attach_command="HERDR_SESSION_NAME=x herdr agent attach old-one") + write_rows(box.ws, [old]) + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "registry-format: 1 -> 2, rows changed: 1" in res.stdout + y = yaml.safe_load((box.mam / "agent-sessions.yaml").read_text()) + assert y["registry_format"] == 2 + conn = sqlite3.connect(str(box.mam / "agent-sessions.db")) + try: + db = {r["name"]: r for r in (json.loads(x[0]) for x in conn.execute("SELECT data FROM sessions"))} + st = json.loads(conn.execute("SELECT data FROM state WHERE id=1").fetchone()[0]) + finally: + conn.close() + assert {r["name"]: r for r in y["herdr_sessions"]} == db and st["registry_format"] == 2 + assert "kill_command" not in db["old-one"] and "start_command" not in db["old-one"] + + +def test_no_registry_is_nothing_to_convert(box): + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "registry: nothing to convert (no registry)" in res.stdout + assert not (box.mam / "agent-sessions.yaml").exists() + + +def test_a_failed_conversion_keeps_the_install_and_exits_nonzero(box, tmp_path): + src = tmp_path / "src" + shutil.copytree(REPO_ROOT, src, symlinks=True, + ignore=shutil.ignore_patterns(".git", ".venv", ".mam", "__pycache__", "tests", "nats-docker")) + fmt = src / ".agents" / "skills" / "lib_py" / "registry_format.py" + fmt.write_text("import sys\nif __name__ == '__main__':\n sys.exit(3)\n" + fmt.read_text()) + box.env["MAM_REPO_URL"] = str(src) + box.stub_monitor() + box.start_monitor() + write_rows(box.ws, [row("a", status="stopped")]) + res = box.update() + assert res.returncode == 3, res.stdout + res.stderr + assert "the registry was not converted" in res.stderr + assert (box.ws / ".agents/skills/lib_py/registry_format.py").read_text() == fmt.read_text(), "install kept" + assert box.monitor_pids() and box.monitor_starts() == 2, "the monitor is started again anyway" + assert not (box.mam / "update-backup").exists() and not (box.mam / "update-in-progress").exists() + + +def test_refuses_outside_an_install_and_without_yes(box): + (box.ws / ".mam_deploy" / "remove.sh").unlink() + res = box.update() + assert res.returncode == 1 and "not an installed MAM workspace" in res.stderr + shutil.copy(REPO_ROOT / "deploy" / "remove.sh", box.ws / ".mam_deploy" / "remove.sh") + res = subprocess.run(["bash", str(box.ws / UPDATE_REL)], env=box.env, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=60) + assert res.returncode == 1 and "-y/--yes" in res.stderr + res = box.update("--force") + assert res.returncode == 2, "--force is not an alias of --yes" + + +# --- refusals (decision 1) --------------------------------------------------------- + +def test_refuses_a_running_row(box): + write_rows(box.ws, [row("busy-creator", cwd="/somewhere/else")]) + before = snapshot(box.ws) + res = box.update() + assert_refused_unchanged(box, before, res, "session busy-creator is running", "stop_session.sh --session busy-creator") + + +def test_refuses_an_unreadable_registry(box): + (box.mam / "agent-sessions.db").write_text("not a database") + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(), "cannot be read") + + +def test_refuses_a_rowless_agent_on_any_running_server(box): + box.herdr(sessions={"default": True, "picked-by-hand": True, "down": False}, + agents={"picked-by-hand": [{"name": "stray", "pane_id": "w3:p1", "cwd": str(box.ws / "sub")}], + "down": [{"name": "ghost", "pane_id": "w4:p1", "cwd": str(box.ws)}]}) + before = snapshot(box.ws) + res = box.update() + assert_refused_unchanged(box, before, res, "herdr agent stray (pane w3:p1, server picked-by-hand)") + assert "ghost" not in res.stderr, "a server that is not running is not asked" + calls = (box.root / "herdr.json.calls").read_text() + assert '"down"' not in calls + + +def test_an_agent_outside_the_workspace_does_not_refuse(box): + box.herdr(agents={"default": [{"name": "other", "pane_id": "w1:p2", "cwd": str(box.root / "elsewhere")}]}) + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + + +@pytest.mark.parametrize("fail", ["session-list", "agent-list:default"]) +def test_refuses_when_herdr_cannot_answer(box, fail): + box.herdr(fail=fail) + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(), "cannot list the live agents") + + +def test_open_jobs_refuse_and_terminal_or_dry_run_do_not(box, tmp_path): + jobs = box.mam / "jobs" + write_job(jobs, "done1", status="completed") + write_job(jobs, "err1", status="error") + write_job(jobs, "can1", status="cancelled") + write_job(jobs, "dry1", status="pending", dry_run=True) + assert box.update().returncode == 0 + + write_job(jobs, "open1", status="pending", session="remote:x") + before = snapshot(box.ws) + res = box.update() + assert_refused_unchanged(box, before, res, "job open1", "close --job open1 --request cancelled") + (jobs / "open1.json").unlink() + + (jobs / "broken.json").write_text("{not json") + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(), "job record %s cannot be read" % (jobs / "broken.json")) + (jobs / "broken.json").unlink() + + extra = tmp_path / "mam-jobs-dir" + write_job(extra, "env1", status="running") + box.env["MAM_JOBS_DIR"] = str(extra) + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(), "job env1") + del box.env["MAM_JOBS_DIR"] + + +def test_other_control_planes_count_only_jobs_addressed_to_this_registry(box, tmp_path): + other = tmp_path / "other-plane" / "jobs" + write_job(other, "theirs", status="running", session="herdr:not-ours") + write_rows(box.ws, [row("wt-creator", status="stopped", control_plane={"mam_jobs_dir": str(other)})]) + assert box.update().returncode == 0, "a job of another registry's session is not ours" + write_job(other, "ours", status="running", session="herdr:wt-creator") + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(), "job ours") + + +def test_loop_marker_live_refuses_stale_does_not(box): + live_marker(box.mam / "loop-guard-active", box) + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(), "a review loop is running") + dead_marker(box.mam / "loop-guard-active") + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "stale loop marker" in res.stdout + + +# --- the calling agent (decision 7, N2) ---------------------------------------------- + +def _me_env(server="mam-x", pane="w5:p1"): + return {"HERDR_SESSION": server, "HERDR_PANE_ID": pane} + + +def test_the_calling_agent_its_row_and_its_running_job_are_excluded(box): + me = {"name": "me-creator", "pane_id": "w5:p1", "cwd": str(box.ws)} + box.herdr(sessions={"default": True, "mam-x": True}, agents={"mam-x": [me]}) + write_rows(box.ws, [row("me-creator", server="mam-x", cwd=str(box.ws))]) + write_job(box.mam / "jobs", "myjob", status="running", session="herdr:me-creator") + res = box.update(env=_me_env()) + assert res.returncode == 0, res.stdout + res.stderr + + write_job(box.mam / "jobs", "myjob2", status="pending", session="herdr:me-creator") + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(env=_me_env()), "job myjob2") + + +def test_same_pane_id_on_another_server_is_not_excluded(box): + box.herdr(sessions={"default": True, "mam-x": True, "mam-y": True}, + agents={"mam-x": [{"name": "me", "pane_id": "w5:p1", "cwd": str(box.ws)}], + "mam-y": [{"name": "twin", "pane_id": "w5:p1", "cwd": str(box.ws)}]}) + before = snapshot(box.ws) + res = box.update(env=_me_env()) + assert_refused_unchanged(box, before, res, "herdr agent twin (pane w5:p1, server mam-y)") + assert "agent me " not in res.stderr + + +def test_a_nameless_caller_pane_is_excluded_by_pane_only(box): + box.herdr(sessions={"default": True, "mam-x": True}, + agents={"mam-x": [{"name": None, "pane_id": "w5:p1", "cwd": str(box.ws)}]}) + assert box.update(env=_me_env()).returncode == 0 + write_job(box.mam / "jobs", "nameless", status="running", session="herdr:") + write_job(box.mam / "jobs", "nonename", status="running", session="herdr:None") + before = snapshot(box.ws) + assert_refused_unchanged(box, before, box.update(env=_me_env()), "job nameless", "job nonename") + + +@pytest.mark.parametrize("env", [{}, {"HERDR_PANE_ID": "w5:p1"}, {"HERDR_SESSION": "mam-x"}]) +def test_nothing_is_excluded_when_the_caller_is_not_identified(box, env): + box.herdr(sessions={"default": True, "mam-x": True}, + agents={"mam-x": [{"name": "me", "pane_id": "w5:p1", "cwd": str(box.ws)}]}) + before = snapshot(box.ws) + res = box.update(env=env) + assert_refused_unchanged(box, before, res, "herdr agent me", "nothing was excluded as the calling agent") + + +# --- the monitor (decision 2, U3) ---------------------------------------------------- + +def test_a_running_monitor_is_stopped_and_started_again(box): + box.stub_monitor() + box.start_monitor() + first = set(box.monitor_pids()) + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "monitor: stopping the daemon" in res.stdout and "monitor: restarted" in res.stdout + now = set(box.monitor_pids()) + assert now and not (now & first) and box.monitor_starts() == 2 + + +def test_no_monitor_before_means_none_after(box): + box.stub_monitor() + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert not box.monitor_pids() and box.monitor_starts() == 0 + + +def test_no_subscriber_daemon_flag_wins(box): + box.stub_monitor() + box.start_monitor() + res = box.update(env={"MAM_NO_SUBSCRIBER_DAEMON": "1"}) + assert res.returncode == 0, res.stdout + res.stderr + assert "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" in res.stdout + assert not box.monitor_pids() + + +def test_a_monitor_that_ignores_term_is_killed_restarted_and_nothing_installed(box): + box.stub_monitor() + box.start_monitor(env={"STUB_MONITOR_IGNORE_TERM": "1"}) + box.installer("touch \"$ws/INSTALLER-RAN\"\n") + before = snapshot(box.ws) + res = box.update(env={"MAM_UPDATE_MONITOR_STOP_S": "1"}) + assert res.returncode == 1, res.stdout + res.stderr + assert "it was killed and is started again" in res.stderr + assert not (box.ws / "INSTALLER-RAN").exists() + assert snapshot(box.ws) == before + assert box.monitor_pids() and box.monitor_starts() == 2 + assert not (box.mam / "update-in-progress").exists() + + +def test_a_monitor_lock_held_by_an_unknown_process_refuses(box): + lock = box.mam / "monitor.lock" + with open(lock, "w") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + before = snapshot(box.ws) + res = box.update() + assert_refused_unchanged(box, before, res, "monitor.lock is held by a monitor the skills did not start") + + +# --- rollback (decisions 3, 10; U4) -------------------------------------------------- + +FAILING_INSTALL = """ +echo broken >> "$ws/.agents/skills/lib.sh" +rm -f "$ws/.mam_deploy/remove.sh" +echo new > "$ws/MESSAGING.md" +mkdir -p "$ws/.venv/lib" && echo junk > "$ws/.venv/lib/junk" +echo junk > "$ws/.mam/version.txt" +echo junk > "$ws/.mam/asset_hashes.txt" +mkdir -p "$ws/.agents/skills/half-installed" && echo x > "$ws/.agents/skills/half-installed/f" +python3 -c 'import yaml,sys; p=sys.argv[1]; d=yaml.safe_load(open(p)); d["herdr_sessions"]=[]; yaml.safe_dump(d, open(p, "w"))' "$ws/.mam/agent-sessions.yaml" +exit 7 +""" + + +def test_a_failed_install_rolls_everything_back(box): + write_rows(box.ws, [row("a", status="stopped")]) + db = box.mam / "agent-sessions.db" + conn = sqlite3.connect(str(db)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA wal_autocheckpoint=0") + conn.execute("CREATE TABLE state (id INTEGER PRIMARY KEY, data TEXT)") + conn.execute("CREATE TABLE sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)") + conn.execute("INSERT INTO state VALUES (1, ?)", (json.dumps({"orchestrator_uuids": ["in-wal"]}),)) + conn.execute("INSERT INTO sessions VALUES ('a', 'stopped', '/x', ?)", (json.dumps(row("a", status="stopped")),)) + conn.commit() + assert (box.mam / "agent-sessions.db-wal").stat().st_size > 0, "the commit is still in the WAL" + box.stub_monitor() + box.start_monitor() + box.installer(FAILING_INSTALL) + before = snapshot(box.ws) + try: + res = box.update() + finally: + conn.close() + assert res.returncode == 1, res.stdout + res.stderr + assert "the install failed (rc 7); rolling back" in res.stderr and "rolled back" in res.stdout + after = snapshot(box.ws) + after.pop(".mam/agent-sessions.db", None) + before.pop(".mam/agent-sessions.db", None) + before.pop(".mam/agent-sessions.db-wal", None) + before.pop(".mam/agent-sessions.db-shm", None) + assert after == before, sorted(set(after.items()) ^ set(before.items()))[:10] + assert not (box.ws / "MESSAGING.md").exists() and not (box.ws / ".venv").exists() + check = sqlite3.connect(str(db)) + try: + st = json.loads(check.execute("SELECT data FROM state WHERE id=1").fetchone()[0]) + finally: + check.close() + assert st == {"orchestrator_uuids": ["in-wal"]}, "the backup carried the WAL's commit" + assert not (box.mam / "update-backup").exists() and not (box.mam / "update-in-progress").exists() + assert box.monitor_pids() and box.monitor_starts() == 2, "the restored tree starts the monitor again" + + box.env["MAM_INSTALLER_URL"] = "file://" + str(REPO_ROOT / "deploy" / "install.sh") + res = box.update() + assert res.returncode == 0, "the skill runs again after a rollback: " + res.stdout + res.stderr + + +# --- leftovers (U4) ------------------------------------------------------------------ + +def _leftover(box: Box, state): + bk = box.mam / "update-backup" + (bk / "files" / ".agents" / "skills").mkdir(parents=True) + shutil.copy2(box.ws / ".agents/skills/lib.sh", bk / "files/.agents/skills/lib.sh") + (bk / "items").write_text(".agents/skills/lib.sh\nMESSAGING.md\n") + (bk / "absent").write_text("MESSAGING.md\n") + (bk / "registry").mkdir() + (bk / "registry" / "agent-sessions.yaml").write_text("herdr_sessions: []\n") + if state is not None: + (bk / "state").write_text(state + "\n") + return bk + + +@pytest.mark.parametrize("state", [None, "backing-up", "installed"]) +def test_leftover_that_changed_nothing_or_finished_is_removed(box, state): + _leftover(box, state) + (box.ws / "MESSAGING.md").write_text("must survive: nothing is restored\n") + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "removing the backup a previous update left" in res.stdout + assert (box.ws / "MESSAGING.md").exists() + assert not (box.mam / "update-backup").exists() + + +def test_leftover_installing_restores_files_but_not_the_registry(box): + _leftover(box, "installing") + pristine = (box.ws / ".agents/skills/lib.sh").read_bytes() + with open(box.ws / ".agents/skills/lib.sh", "a") as fh: + fh.write("# half installed\n") + (box.ws / "MESSAGING.md").write_text("made by the dead install\n") + write_rows(box.ws, [row("written-after-crash", status="stopped")]) + box.installer("grep -q 'half installed' \"$ws/.agents/skills/lib.sh\" && exit 9\n" + "test -e \"$ws/MESSAGING.md\" && exit 8\n" + "cp \"$ws/.mam/agent-sessions.yaml\" \"$ws/../seen-registry.yaml\"\nexit 0\n") + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "the previous update was rolled back" in res.stdout + assert (box.ws / ".agents/skills/lib.sh").read_bytes() == pristine + assert "written-after-crash" in (box.root / "seen-registry.yaml").read_text(), "registry left as it was" + assert not (box.mam / "update-backup").exists() + + +def test_leftover_whose_restore_fails_is_kept_and_nothing_more_changes(box): + bk = _leftover(box, "installing") + (bk / "items").write_text(".agents/skills/lib.sh\n.agents/skills/missing-from-backup\n") + box.stub_monitor() + box.start_monitor() + box.installer("touch \"$ws/INSTALLER-RAN\"\n") + res = box.update() + assert res.returncode == 1, res.stdout + res.stderr + assert "restoring the previous update's backup failed" in res.stderr + assert bk.exists() and (bk / "state").read_text().strip() == "installing" + assert not (box.ws / "INSTALLER-RAN").exists() + assert not box.monitor_pids(), "the monitor stays stopped" + assert not (box.mam / "update-in-progress").exists() + + +def test_the_backup_lives_in_the_workspace_and_is_gone_after_success(box): + box.installer("test -f \"$ws/.mam/update-backup/state\" && grep -qx installing \"$ws/.mam/update-backup/state\" " + "&& test -f \"$ws/.mam/update-backup/files/.agents/skills/lib.sh\" && touch \"$ws/../saw-backup\"\n") + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert (box.root / "saw-backup").exists() + assert not (box.mam / "update-backup").exists() + + +# --- the marker and the admission lock (decision 8) ----------------------------------- + +def test_a_live_update_marker_refuses_a_second_update_and_is_left_alone(box): + live_marker(box.mam / "update-in-progress", box) + text = (box.mam / "update-in-progress").read_text() + before = snapshot(box.ws) + res = box.update() + assert res.returncode == 1 and "another update is in progress" in res.stderr, res.stderr + assert (box.mam / "update-in-progress").read_text() == text + assert snapshot(box.ws) == before + + +def test_a_stale_update_marker_is_taken_over(box): + dead_marker(box.mam / "update-in-progress") + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "stale update marker" in res.stdout + assert not (box.mam / "update-in-progress").exists() + + +def test_two_updates_at_once_one_proceeds(box): + box.installer("touch \"$ws/../first-installing\"; sleep 4; exit 0\n") + first = box.helper("bash", str(box.ws / UPDATE_REL), "--yes", cwd=str(box.ws), env=box.env, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + _until(lambda: (box.root / "first-installing").exists()) + marker = (box.mam / "update-in-progress").read_text() + second = box.update() + assert second.returncode == 1 and "another update is in progress" in second.stderr, second.stderr + assert (box.mam / "update-in-progress").read_text() == marker, "the running update's marker is untouched" + out, err = first.communicate(timeout=120) + assert first.returncode == 0, out + err + + +def test_entries_are_refused_while_the_copy_installs(box): + box.installer('if bash -c \'source "$1/.agents/skills/lib.sh" && mam_update_admit\' _ "$ws" 2> "$ws/../admit.err"; ' + 'then echo admitted > "$ws/../admit"; else echo refused > "$ws/../admit"; fi\n') + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + assert (box.root / "admit").read_text().strip() == "refused" + assert "an update of %s is in progress" % box.ws in (box.root / "admit.err").read_text() + + +@pytest.mark.parametrize("wait,needle", [("30", "session late-row is running"), ("1", "admission lock past")]) +def test_an_entry_holding_the_admission_lock_is_waited_for_or_refuses(box, wait, needle): + script = ('mam_update_admit || exit 1\necho admitted\nsleep 3\n' + 'printf "herdr_sessions:\\n- name: late-row\\n status: running\\n" > "$WORKSPACE_ROOT/.mam/agent-sessions.yaml"\n' + 'mam_update_admit_release\nsleep 30\n') + entry = box.helper("bash", "-c", 'source "$1/.agents/skills/lib.sh"\n' + script, "_", str(box.ws), + env=dict(box.env, WORKSPACE_ROOT=str(box.ws)), stdout=subprocess.PIPE, text=True) + assert entry.stdout.readline().strip() == "admitted" + box.installer("touch \"$ws/INSTALLER-RAN\"\n") + res = box.update(env={"MAM_UPDATE_ADMISSION_WAIT_S": wait}) + assert res.returncode == 1, res.stdout + res.stderr + assert needle in res.stderr, res.stderr + assert not (box.ws / "INSTALLER-RAN").exists() + assert not (box.mam / "update-in-progress").exists() diff --git a/tests/test_issue8_update_state.py b/tests/test_issue8_update_state.py index e572e01..8b5545d 100644 --- a/tests/test_issue8_update_state.py +++ b/tests/test_issue8_update_state.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -"""Issue #8 — update.sh must keep the installer's fresh version.txt and -asset_hashes.txt instead of restoring the pre-update copies. +"""Issue #8 — an update must keep the installer's fresh version.txt and +asset_hashes.txt instead of restoring the pre-update copies. Since Issue #72 +the update is the multi-agent-mux-update skill (in place, no remove.sh) and +no .mam_deploy/update.sh is installed. Restoring them left version.txt naming the previous commit and made remove.sh compare against stale hashes, so every updated-but-pristine file was backed up @@ -51,8 +53,19 @@ def test_update_keeps_fresh_version_and_hashes_and_merges_install_state(installe with open(mam / "install_state", "a") as fh: fh.write("custom_marker=kept\n") - res = subprocess.run(["bash", str(ws / ".mam_deploy" / "update.sh"), "--force"], - cwd=str(ws), env=env, capture_output=True, text=True, timeout=600) + assert not (ws / ".mam_deploy" / "update.sh").exists() + upd_env = {k: v for k, v in env.items() if not k.startswith("HERDR_") + and k not in ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR")} + upd_env["MAM_NO_SUBSCRIBER_DAEMON"] = "1" + # The update lists the live herdr agents; never ask a real server here. + fake = ws.parent / "fake-herdr-bin" + fake.mkdir() + (fake / "herdr").write_text('#!/bin/sh\necho \'{"sessions": []}\'\n') + (fake / "herdr").chmod(0o755) + upd_env["PATH"] = "%s:%s" % (fake, upd_env["PATH"]) + res = subprocess.run(["bash", str(ws / ".agents/skills/multi-agent-mux-update/scripts/update.sh"), "--yes"], + cwd=str(ws), env=upd_env, capture_output=True, text=True, timeout=600, + stdin=subprocess.DEVNULL) assert res.returncode == 0, res.stderr + res.stdout version = (mam / "version.txt").read_text() @@ -64,3 +77,4 @@ def test_update_keeps_fresh_version_and_hashes_and_merges_install_state(installe state = (mam / "install_state").read_text() assert "custom_marker=kept" in state, state assert state.count("gitignore_created=1") == 1, "merged keys must not be duplicated:\n" + state + assert not (ws / ".mam_deploy" / "update.sh").exists() -- 2.54.0 From fce9459e42442ea700eb4902a7cb7bf7b0d6a6cd Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:07:42 +0000 Subject: [PATCH 05/16] test(update): the first transition reaches the skill and the conversion (Issue #72, criterion 5) A v4.11.0 workspace's .mam_deploy/update.sh installs the new version - the skill present, no updater left - and the skill run after it converts the registry. An update-backup in an unknown state now restarts the monitor it stopped: nothing was changed. Co-Authored-By: Claude Opus 5.5 --- tests/test_issue72_update.py | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py index f554bae..52bd0cf 100644 --- a/tests/test_issue72_update.py +++ b/tests/test_issue72_update.py @@ -708,3 +708,40 @@ def test_an_entry_holding_the_admission_lock_is_waited_for_or_refuses(box, wait, assert needle in res.stderr, res.stderr assert not (box.ws / "INSTALLER-RAN").exists() assert not (box.mam / "update-in-progress").exists() + + +# --- the first transition (decision 4, criterion 5) ------------------------------------ + +def test_the_old_updater_installs_the_skill_which_then_converts_the_registry(tmp_path): + """A workspace installed by v4.11.0 still has .mam_deploy/update.sh. Run once, + it installs the new version - the skill, and no updater left behind - and + copies the registry unconverted; the skill run right after converts it.""" + old_src = tmp_path / "v4.11.0" + old_src.mkdir() + arch = subprocess.run(["git", "-C", str(REPO_ROOT), "archive", "0e1e2e8"], capture_output=True) + if arch.returncode != 0: + pytest.skip("no git history to rebuild v4.11.0 from") + subprocess.run(["tar", "-x", "-C", str(old_src)], input=arch.stdout, check=True) + root = Path(os.path.realpath(tmp_path)) + ws = root / "ws" + ws.mkdir() + (root / "bin").mkdir() + (root / "bin" / "herdr").write_text(FAKE_HERDR) + (root / "bin" / "herdr").chmod(0o755) + b = Box(root, ws) + res = subprocess.run(["bash", str(old_src / "deploy" / "install.sh"), str(ws)], + env=dict(b.env, MAM_REPO_URL=str(old_src)), capture_output=True, text=True, timeout=600) + assert res.returncode == 0, res.stdout + res.stderr + assert (ws / ".mam_deploy" / "update.sh").exists() + write_rows(ws, [row("old-one", status="stopped", kill_command="x", start_command="y")]) + + res = subprocess.run(["bash", str(ws / ".mam_deploy" / "update.sh"), "--force"], cwd=str(ws), env=b.env, + capture_output=True, text=True, timeout=600, stdin=subprocess.DEVNULL) + assert res.returncode == 0, res.stdout + res.stderr + assert (ws / UPDATE_REL).exists() and not (ws / ".mam_deploy" / "update.sh").exists() + assert "registry_format" not in yaml.safe_load((ws / ".mam" / "agent-sessions.yaml").read_text()) + + res = b.update() + assert res.returncode == 0, res.stdout + res.stderr + assert "registry-format: 1 -> 2, rows changed: 1" in res.stdout + assert yaml.safe_load((ws / ".mam" / "agent-sessions.yaml").read_text())["registry_format"] == 2 -- 2.54.0 From 515b3206b955daacb1a73e3c2d95dd0784f59156 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:08:57 +0000 Subject: [PATCH 06/16] chore(shellcheck): no new findings in the update skill, lib.sh and install.sh (Issue #72) CI now shellchecks the update skill's scripts: SC1091 is disabled at their `source` lines as in the other skills, and the two findings this branch added (lib.sh's loop_lock.sh source, install.sh's one-item legacy loop) are disabled at their lines. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 1 + .agents/skills/multi-agent-mux-update/scripts/post_update.sh | 2 +- .agents/skills/multi-agent-mux-update/scripts/update.sh | 4 ++-- deploy/install.sh | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 4951ec4..a5c4933 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3284,6 +3284,7 @@ mam_update_admit() { return 1 ;; esac [ -f "$marker" ] || return 0 + # shellcheck disable=SC1091 declare -F _mam_lock_holder_state >/dev/null || source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" st="$(_mam_lock_holder_state "$marker")" case "$st" in diff --git a/.agents/skills/multi-agent-mux-update/scripts/post_update.sh b/.agents/skills/multi-agent-mux-update/scripts/post_update.sh index 9330952..5d4ef7b 100755 --- a/.agents/skills/multi-agent-mux-update/scripts/post_update.sh +++ b/.agents/skills/multi-agent-mux-update/scripts/post_update.sh @@ -13,7 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)" export WORKSPACE_ROOT="$WS" export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml" -# shellcheck source=../../lib.sh +# shellcheck disable=SC1091 source "$WS/.agents/skills/lib.sh" MON=0 diff --git a/.agents/skills/multi-agent-mux-update/scripts/update.sh b/.agents/skills/multi-agent-mux-update/scripts/update.sh index a083283..652f331 100755 --- a/.agents/skills/multi-agent-mux-update/scripts/update.sh +++ b/.agents/skills/multi-agent-mux-update/scripts/update.sh @@ -22,9 +22,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WS="$(cd -P "$SCRIPT_DIR/../../../.." && pwd -P)" export WORKSPACE_ROOT="$WS" export AGENT_SESSIONS_YAML="$WS/.mam/agent-sessions.yaml" -# shellcheck source=../../lib.sh +# shellcheck disable=SC1091 source "$WS/.agents/skills/lib.sh" -# shellcheck source=../../multi-agent-mux-loop/scripts/loop_lock.sh +# shellcheck disable=SC1091 source "$SKILL_DIR/multi-agent-mux-loop/scripts/loop_lock.sh" YES=0 diff --git a/deploy/install.sh b/deploy/install.sh index 488ac58..4f7ace2 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -167,6 +167,7 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre # Migrate legacy root layout for remove.sh into .mam_deploy/ if owned mkdir -p .mam_deploy + # shellcheck disable=SC2043 # one legacy script left (update.sh went with Issue #72) for legacy_script in remove.sh; do if [ -f "$legacy_script" ] && grep -Fqx "$legacy_script" "$MANIFEST_FILE" 2>/dev/null; then mv -f "$legacy_script" ".mam_deploy/$legacy_script" -- 2.54.0 From 8f325de97949c595f2d1b2238ef7e457f47e24ff Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:16:38 +0000 Subject: [PATCH 07/16] docs(resume): the flow ends with the monitor daemon (Issue #72) Co-Authored-By: Claude Opus 5.5 --- .agents/skills/multi-agent-mux-resume/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 63f7974..a75acdc 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -60,7 +60,7 @@ If the resolved id is empty → for Class A agents (`agy`, `hermes`, `opencode`, bash .agents/skills/multi-agent-mux-resume/scripts/resume_session.sh --workspace --agent --session ``` -`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: the row's `attach_command`, `herdr --session agent attach ` (`` = the `--herdr-session` you passed, or the row's `herdr_session`; `` = the name herdr registered). +`resume_session.sh` does the whole flow: original-cwd check, control-plane binding, the session-id decision (`lib_py/agents/session_ids.py`), the herdr-alive check, the agent command (adapter `resume-spec`, or `spawn-spec` for a Class A fresh spawn), spawn (just before it, the session's own empty shell left by a server restart is closed: same session id, no agent, nothing running; `lib_py/herdr_restore.py`), start-up dialogs, and the YAML update (`update_yaml_resumed.sh`, which also publishes a `progress "resumed"` event when the row has a `delegate_job_id`), then the control root's monitor daemon (`start_watchdog`, as create; not with `MAM_NO_SUBSCRIBER_DAEMON=1`). It never attaches; attaching is a separate step that names the server, because the server the script exports does not reach the calling shell and the native `herdr` binary does not route on environment variables: the row's `attach_command`, `herdr --session agent attach ` (`` = the `--herdr-session` you passed, or the row's `herdr_session`; `` = the name herdr registered). | Option | Meaning | |---|---| -- 2.54.0 From 595e34b2d134389865b4d7766eb68fb200a3d52d Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 04:48:43 +0000 Subject: [PATCH 08/16] fix(gate): no admission lock in a tree without .mam; MAM_UPDATE_MONITOR_STOP_S in the knob table (Issue #72) mam_update_admit created .mam/ to hold its lock, so a checkout running its own run_loop.sh got a .mam (test_loop_marker_isolation L1, test_issue17 W1). A tree without .mam is not an installed workspace, the only kind the update runs in, so the gate admits there without a lock. The update's monitor-stop wait is a normalised integer knob and is listed in INT_KNOBS (test_issue26_ints B5). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 8 ++++++-- tests/test_issue26_ints.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index a5c4933..c65686a 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3264,11 +3264,15 @@ mam_control_plane_prefix() { # fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by # MAM_UPDATE_ADMISSION_WAIT_S (default 120). # -# mam_update_admit [workspace]: rc 0 admitted (fd 7 held), 1 refused (stderr). +# mam_update_admit [workspace]: rc 0 admitted (fd 7 held, unless the workspace +# has no .mam), 1 refused (stderr). mam_update_admit() { local root="${1:-$WORKSPACE_ROOT}" local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st - if ! { mkdir -p "$root/.mam" && exec 7>>"$lock"; }; then + # No .mam: not an installed workspace, which is all the update runs in (and a + # checkout running its own scripts gets no .mam from this). + [ -d "$root/.mam" ] || return 0 + if ! exec 7>>"$lock"; then echo "ERROR: cannot open the update admission lock $lock" >&2 return 1 fi diff --git a/tests/test_issue26_ints.py b/tests/test_issue26_ints.py index 703bf3d..b613a3f 100644 --- a/tests/test_issue26_ints.py +++ b/tests/test_issue26_ints.py @@ -59,6 +59,8 @@ INT_KNOBS = { # once) and the wrapper's registry wait (0: decide at once - clear or busy) "MAM_ONBOARD_WAIT_S": (1, "600"), "MAM_DISPATCH_WAIT_S": (0, "600"), + # Issue #72: how long the update waits for the monitor to stop after SIGTERM + "MAM_UPDATE_MONITOR_STOP_S": (1, "30"), # delegate_wait_started's ack_s (MAM_ONBOARD_ACK_S / DELEGATE_JOB_ACK_S via create_session.sh) "delegate_wait_started:ack_s": (0, "180"), # wrapper flags -- 2.54.0 From e3394655b3e8d954f1b4a744bc1be7df5288a54c Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 08:15:23 +0000 Subject: [PATCH 09/16] fix(delegate): submit also takes the target workspace's update admission (Issue #72, review R1) Decision 8: delegate submit uses the admission lock of the workspace the target session belongs to. The wrapper resolved only its own WORKSPACE_ROOT, so a job could be registered and delivered to an agent of another installed workspace while that workspace was being updated. lib_py.update target-workspace resolves a herdr: row's workspace (the .mam at or above its pane.cwd, else its control_plane.workspace_root); mam_update_admit takes an fd, and submit holds the caller root on fd 7 and the target on fd 8 until the record is written. Rowless and remote targets keep the caller-root gate. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 19 +++--- .agents/skills/lib_py/update.py | 40 ++++++++++++ .../multi-agent-mux-delegate-job | 19 ++++-- tests/test_issue72_admission.py | 61 ++++++++++++++++++- 4 files changed, 123 insertions(+), 16 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index c65686a..90d08ff 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3264,26 +3264,27 @@ mam_control_plane_prefix() { # fd 7 (`7>&-` on such a child, as pane_lock's fd 9). The wait is bounded by # MAM_UPDATE_ADMISSION_WAIT_S (default 120). # -# mam_update_admit [workspace]: rc 0 admitted (fd 7 held, unless the workspace -# has no .mam), 1 refused (stderr). +# mam_update_admit [workspace [fd]]: rc 0 admitted (fd 7, or the given one, +# held unless the workspace has no .mam), 1 refused (stderr). Delegate submit +# also takes its target's workspace on fd 8. mam_update_admit() { - local root="${1:-$WORKSPACE_ROOT}" + local root="${1:-$WORKSPACE_ROOT}" fd="${2:-7}" local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st # No .mam: not an installed workspace, which is all the update runs in (and a # checkout running its own scripts gets no .mam from this). [ -d "$root/.mam" ] || return 0 - if ! exec 7>>"$lock"; then + if ! eval "exec $fd>>\"\$lock\""; then echo "ERROR: cannot open the update admission lock $lock" >&2 return 1 fi - case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd 7 --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in + case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd "$fd" --shared --wait-env MAM_UPDATE_ADMISSION_WAIT_S)" in locked) ;; timeout) - exec 7>&- + mam_update_admit_release "$fd" echo "ERROR: an update of $root holds its admission lock (MAM_UPDATE_ADMISSION_WAIT_S passed); try again when it has finished" >&2 return 1 ;; *) - exec 7>&- + mam_update_admit_release "$fd" echo "ERROR: the update admission lock $lock could not be decided (lib_py.pane_lock)" >&2 return 1 ;; esac @@ -3296,11 +3297,11 @@ mam_update_admit() { echo "NOTE: stale update marker $marker (its update is gone); ignored" >&2 return 0 ;; esac - exec 7>&- + mam_update_admit_release "$fd" echo "ERROR: an update of $root is in progress (marker $marker, holder $st); try again when it has finished" >&2 return 1 } mam_update_admit_release() { - exec 7>&- + eval "exec ${1:-7}>&-" } diff --git a/.agents/skills/lib_py/update.py b/.agents/skills/lib_py/update.py index b9372e4..9d326c8 100644 --- a/.agents/skills/lib_py/update.py +++ b/.agents/skills/lib_py/update.py @@ -16,6 +16,11 @@ this module what needs parsing. Standard library only, Python 3.9. python -m lib_py.update monitor --workspace W JSON {"pids": [...], "lock": "free"|"held"}: the monitor daemons daemon.find_running recognises, and whether .mam/monitor.lock is held. + python -m lib_py.update target-workspace --workspace W --agent-session S + The installed workspace a delegate target `herdr:` belongs to + (decision 8), when it is not W: the nearest directory holding a .mam + at or above its row's pane.cwd, else the row's control_plane + workspace_root. Nothing for a rowless or non-herdr target. python -m lib_py.update backup-registry --workspace W --dest D Copies the registry into D under the registry's own write lock (agent-sessions.db.lock, as atomic_yaml takes it): the DB through the @@ -231,6 +236,31 @@ def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> return blockers +# --------------------------------------------------------------------------- delegate target + +def target_workspace(workspace: str, state: dict, agent_session: str) -> str: + if not agent_session.startswith("herdr:"): + return "" + name = agent_session[len("herdr:"):] + row = next((s for s in state.get("herdr_sessions") or [] + if isinstance(s, dict) and s.get("name") == name), None) + if row is None: + return "" + found = "" + cwd = (row.get("pane") or {}).get("cwd") or "" + d = _real(cwd) if cwd else "" + while d and not found: + if os.path.isdir(os.path.join(d, ".mam")): + found = d + elif os.path.dirname(d) == d: + break + d = os.path.dirname(d) + if not found: + root = (row.get("control_plane") or {}).get("workspace_root") or "" + found = _real(root) if root else "" + return "" if found == _real(workspace) else found + + # --------------------------------------------------------------------------- monitor def monitor_state(workspace: str) -> dict: @@ -288,6 +318,9 @@ def main(argv: Optional[List[str]] = None) -> int: s.add_argument("--herdr", required=True) s = sub.add_parser("monitor") s.add_argument("--workspace", required=True) + s = sub.add_parser("target-workspace") + s.add_argument("--workspace", required=True) + s.add_argument("--agent-session", required=True) s = sub.add_parser("backup-registry") s.add_argument("--workspace", required=True) s.add_argument("--dest", required=True) @@ -303,6 +336,13 @@ def main(argv: Optional[List[str]] = None) -> int: for line in lines: print(line) return 1 if lines else 0 + if a.cmd == "target-workspace": + try: + state = _state() + except ValueError: + state = {} + print(target_workspace(ws, state, a.agent_session)) + return 0 if a.cmd == "monitor": print(json.dumps(monitor_state(ws))) return 0 diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index 6223140..1b1e488 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -301,10 +301,19 @@ cmd_submit() { local reg_dry=() if [[ "$DRY_RUN" == "1" ]]; then reg_dry=(--dry-run); fi - # Issue #72: no job is registered while an update of the target's workspace - # runs; held until the record is written (lib.sh mam_update_admit). A - # dry-run record is never dispatched and is not counted by the update. - if [[ "$DRY_RUN" != "1" ]]; then mam_update_admit "$WORKSPACE_ROOT" || exit 1; fi + # Issue #72: no job is registered while an update of this registry's + # workspace or of the target's (its row's workspace, lib_py.update + # target-workspace) runs; held until the record is written (lib.sh + # mam_update_admit). A dry-run record is never dispatched and is not + # counted by the update. + if [[ "$DRY_RUN" != "1" ]]; then + local target_ws + target_ws="$(MAM_STATE_JSON="$(load_state_json)" PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.update \ + target-workspace --workspace "$WORKSPACE_ROOT" --agent-session "$AGENT_SESSION")" \ + || { echo "ERROR: cannot resolve the workspace of $AGENT_SESSION" >&2; exit 1; } + mam_update_admit "$WORKSPACE_ROOT" || exit 1 + if [[ -n "$target_ws" ]]; then mam_update_admit "$target_ws" 8 || exit 1; fi + fi # 1) register job (prints the new job id) JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ "${reg_prompt[@]}" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \ @@ -314,7 +323,7 @@ cmd_submit() { --job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \ --max-iterations "$MAX_ITERATIONS" ${reg_parent[@]+"${reg_parent[@]}"} ${reg_dry[@]+"${reg_dry[@]}"})" echo "registered job: $JOB_ID" - [[ "$DRY_RUN" == "1" ]] || mam_update_admit_release + [[ "$DRY_RUN" == "1" ]] || { mam_update_admit_release; mam_update_admit_release 8; } # 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring) local job_dir="$REGISTRY_DIR/$JOB_ID" diff --git a/tests/test_issue72_admission.py b/tests/test_issue72_admission.py index 7df3e44..a8a395f 100644 --- a/tests/test_issue72_admission.py +++ b/tests/test_issue72_admission.py @@ -12,6 +12,7 @@ import hashlib import json import os import subprocess +import sys from pathlib import Path import pytest @@ -26,8 +27,8 @@ ORC_OLD = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" def marker(mam_sandbox): procs = [] - def make(live: bool): - p = mam_sandbox / ".mam" / "update-in-progress" + def make(live: bool, root: Path = None): + p = (root or mam_sandbox) / ".mam" / "update-in-progress" if live: proc = subprocess.Popen(["sleep", "120"], start_new_session=True) procs.append(proc) @@ -143,6 +144,62 @@ def test_delegate_submit_registers_no_job(mam_sandbox, mock_herdr, marker): assert not list(jobs.glob("*.json")) if jobs.exists() else True +@pytest.fixture +def other_ws(tmp_path): + """A second installed workspace B, outside the sandbox (A).""" + b = Path(os.path.realpath(tmp_path)) / "ws-b" + (b / ".mam").mkdir(parents=True) + (b / "sub").mkdir() + return b + + +def _submit_to(sandbox, session, env=None): + return _run(sandbox, ["bash", _script(sandbox, "multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"), + "submit", "--agent", "claude-code", "--agent-session", session, "--prompt", "hi"], env=env) + + +def _no_job(sandbox): + jobs = sandbox / ".mam" / "jobs" + return not jobs.exists() or not list(jobs.glob("*.json")) + + +@pytest.mark.parametrize("binding", ["pane-cwd", "control-plane"]) +def test_delegate_submit_takes_the_target_workspace_marker(mam_sandbox, mock_herdr, marker, other_ws, binding): + """R1: the target row's workspace (the .mam at or above its pane.cwd, else its + control_plane.workspace_root) is checked, not only the caller's.""" + r = _row("tgt") + if binding == "pane-cwd": + r["pane"]["cwd"] = str(other_ws / "sub") + else: + r["pane"]["cwd"] = "/nonexistent/tgt" + r["control_plane"] = {"workspace_root": str(other_ws)} + _seed(mam_sandbox, [r]) + marker(True, other_ws) + res = _submit_to(mam_sandbox, "herdr:tgt") + assert res.returncode != 0, res.stdout + res.stderr + assert "an update of %s is in progress" % other_ws in res.stderr, res.stdout + res.stderr + assert "registered job" not in res.stdout + assert _no_job(mam_sandbox) + + +def test_delegate_submit_waits_for_the_target_workspace_admission_lock(mam_sandbox, mock_herdr, other_ws): + _seed(mam_sandbox, [dict(_row("tgt"), pane={"cwd": str(other_ws), "cmd": "claude"})]) + holder = subprocess.Popen([sys.executable, "-c", "import fcntl, sys, time\n" + "f = open(sys.argv[1], 'a'); fcntl.flock(f, fcntl.LOCK_EX)\n" + "print('locked', flush=True); time.sleep(120)", + str(other_ws / ".mam" / "update-admission.lock")], + stdout=subprocess.PIPE, text=True, start_new_session=True) + try: + assert holder.stdout.readline().strip() == "locked" + res = _submit_to(mam_sandbox, "herdr:tgt", env={"MAM_UPDATE_ADMISSION_WAIT_S": "1"}) + finally: + holder.kill() + holder.wait() + assert res.returncode != 0, res.stdout + res.stderr + assert "an update of %s holds its admission lock" % other_ws in res.stderr, res.stdout + res.stderr + assert _no_job(mam_sandbox) + + @pytest.mark.parametrize("purge", [False, True]) def test_stop_refuses_and_changes_nothing(mam_sandbox, mock_herdr, marker, purge): _seed(mam_sandbox, [_row("gated-stop")]) -- 2.54.0 From ba75718ac3eb583f2b396c34a067c067dae4a0c5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 08:15:23 +0000 Subject: [PATCH 10/16] fix(update): an unlistable jobs directory refuses; the rowless-agent hint names its server (Issue #72, review R2, R3) R2: glob() hid a listing error, so an unreadable .mam/jobs (or MAM_JOBS_DIR, or another row's jobs dir) counted as empty and the update installed over an open job. Records are now listed with os.listdir; a directory that cannot be listed, or stat'ed, is a blocker with its path. R3: a discovered agent with no row was told to run stop with --session only; stop then looked on the workspace's server, not the one the agent was found on. The hint now carries --herdr-session , quoted. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/update.py | 46 +++++++++++---- .../skills/multi-agent-mux-update/SKILL.md | 2 +- tests/test_issue72_update.py | 59 +++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/.agents/skills/lib_py/update.py b/.agents/skills/lib_py/update.py index 9d326c8..99fe496 100644 --- a/.agents/skills/lib_py/update.py +++ b/.agents/skills/lib_py/update.py @@ -31,9 +31,9 @@ from __future__ import annotations import argparse import fcntl -import glob import json import os +import shlex import shutil import sqlite3 import subprocess @@ -109,7 +109,14 @@ def server_agents(herdr: str, server: str, env: Mapping[str, str]) -> List[dict] # --------------------------------------------------------------------------- jobs def _job_records(d: str) -> Iterable[Tuple[str, Optional[dict]]]: - for p in sorted(glob.glob(os.path.join(d, "*.json"))): + """(path, record) per *.json; (path, None) when it cannot be read, and + (d, None) when d itself cannot be listed (not an empty registry).""" + try: + names = sorted(n for n in os.listdir(d) if n.endswith(".json") and not n.startswith(".")) + except OSError: + yield d, None + return + for p in (os.path.join(d, n) for n in names): try: with open(p, encoding="utf-8") as fh: rec = json.load(fh) @@ -119,6 +126,18 @@ def _job_records(d: str) -> Iterable[Tuple[str, Optional[dict]]]: yield p, rec if isinstance(rec, dict) else None +def _is_jobs_dir(d: str) -> bool: + """d is a directory, or cannot be looked at (an unreadable parent): only an + absent d (or a file) is skipped.""" + try: + os.stat(d) + except (FileNotFoundError, NotADirectoryError): + return False + except OSError: + return True + return os.path.isdir(d) + + def _is_open(rec: dict) -> bool: return rec.get("status") not in TERMINAL_STATUSES and rec.get("dry_run") is not True @@ -127,10 +146,10 @@ def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict] """Every open record (not completed/error/cancelled, not dry_run) in this workspace's .mam/jobs and MAM_JOBS_DIR; in another row's control_plane.mam_jobs_dir only those addressed to a row of this registry. - A record that cannot be read is open (path only).""" + A record, or a directory, that cannot be read is open (path only).""" own, seen = [], set() for c in (os.path.join(workspace, ".mam", "jobs"), env.get("MAM_JOBS_DIR") or ""): - if c and os.path.isdir(c) and _real(c) not in seen: + if c and _is_jobs_dir(c) and _real(c) not in seen: seen.add(_real(c)) own.append(c) rows = state.get("herdr_sessions") or [] @@ -138,7 +157,7 @@ def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict] other = [] for s in rows: c = (s.get("control_plane") or {}).get("mam_jobs_dir") or "" - if c and os.path.isdir(c) and _real(c) not in seen: + if c and _is_jobs_dir(c) and _real(c) not in seen: seen.add(_real(c)) other.append(c) out = [] @@ -146,7 +165,7 @@ def open_jobs(workspace: str, state: dict, env: Mapping[str, str]) -> List[dict] for d in dirs: for path, rec in _job_records(d): if rec is None: - out.append({"path": path, "unreadable": True}) + out.append({"path": path, "unreadable": True, "dir": path == d}) elif _is_open(rec) and (not addressed_only or rec.get("agent_session") in names): out.append({"path": path, "job_id": rec.get("job_id") or "", "agent_session": rec.get("agent_session") or "", @@ -161,6 +180,9 @@ def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> blockers: List[str] = [] stop = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s" " (resume it after the update with multi-agent-mux-resume)") + # A rowless agent: stop finds no row to take its server from (#71). + stop_rowless = ("bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session %s" + " --herdr-session %s") # The caller (decision 7): its server and pane, both or nothing. me_server, me_pane = caller_server(env), env.get("HERDR_PANE_ID") or "" @@ -193,7 +215,7 @@ def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> for s in rows: if s.get("status") == "running" and s is not me_row: blockers.append("session %s is running (server %s); stop it: %s" - % (s.get("name"), row_server(s), stop % s.get("name"))) + % (s.get("name"), row_server(s), stop % shlex.quote(s.get("name") or ""))) row_keys = {(row_server(s), sanitize_herdr_agent_name(s["name"])) for s in rows if s.get("status") == "running" and s.get("name")} @@ -209,8 +231,9 @@ def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> continue # already listed as its running row blockers.append("herdr agent %s (pane %s, server %s) runs in %s; stop it: %s" % (name or "", a.get("pane_id"), server, a.get("cwd"), - stop % name if name else - "herdr --session %s pane close %s" % (server, a.get("pane_id")))) + stop_rowless % (shlex.quote(name), shlex.quote(server)) if name else + "herdr --session %s pane close %s" + % (shlex.quote(server), shlex.quote(a.get("pane_id") or "")))) jobs = open_jobs(workspace, state, env) if me_row is not None: @@ -220,7 +243,10 @@ def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> if len(mine) == 1: jobs = [j for j in jobs if j is not mine[0]] for j in jobs: - if j.get("unreadable"): + if j.get("dir"): + blockers.append("jobs directory %s cannot be listed; fix its permissions, then try again" + % j["path"]) + elif j.get("unreadable"): blockers.append("job record %s cannot be read; check it, then move or delete it by hand" % j["path"]) else: diff --git a/.agents/skills/multi-agent-mux-update/SKILL.md b/.agents/skills/multi-agent-mux-update/SKILL.md index c8b5829..c322b63 100644 --- a/.agents/skills/multi-agent-mux-update/SKILL.md +++ b/.agents/skills/multi-agent-mux-update/SKILL.md @@ -22,7 +22,7 @@ bash .agents/skills/multi-agent-mux-update/scripts/update.sh --yes |---|---| | Where | An installed workspace only (`.mam_deploy/remove.sh` present). A git checkout updates with `git pull`. | | Confirmation | `[y/N]` prompt; without a terminal `-y`/`--yes` is required. There is no force option. | -| Refused when | a running registry row, a live herdr agent whose cwd is in the workspace (any running server), an open job (unreadable job JSON included, `dry_run` excluded), a live loop marker, `.mam/monitor.lock` held by a monitor the skills did not start, or another update. The calling agent (its herdr server + pane, row and one running job) is not counted. Each refusal lists the cleanup; nothing is changed. | +| Refused when | a running registry row, a live herdr agent whose cwd is in the workspace (any running server), an open job (an unreadable job JSON or jobs directory included, `dry_run` excluded), a live loop marker, `.mam/monitor.lock` held by a monitor the skills did not start, or another update. The calling agent (its herdr server + pane, row and one running job) is not counted. Each refusal lists the cleanup; nothing is changed. | | During the update | create, resume, `run_loop.sh`, delegate submit, stop and orc-onboard refuse (`.mam/update-in-progress`, `.mam/update-admission.lock`). | | Monitor | stopped first, started again afterwards only if it ran (`MAM_NO_SUBSCRIBER_DAEMON=1`: not started). | | Install | the installer at `MAM_INSTALLER_URL` (default: Gitea `main`) runs in place; `.mam` is kept. On failure `.agents/`, `.mam_deploy/`, root files, `.venv`, the installer's `.mam` files and the registry are restored from `.mam/update-backup/`. | diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py index 52bd0cf..2d3284f 100644 --- a/tests/test_issue72_update.py +++ b/tests/test_issue72_update.py @@ -45,6 +45,15 @@ elif a[:2] == ["agent", "list"]: if spec.get("fail") == "agent-list:" + server: sys.exit(1) print(json.dumps({"result": {"agents": spec.get("agents", {}).get(server, [])}})) +elif a[:2] == ["agent", "get"]: + hit = [x for x in spec.get("agents", {}).get(server, []) if a[2] in (x.get("name"), x.get("pane_id"))] + if not hit: + sys.exit(1) + print(json.dumps({"result": {"agent": hit[0]}})) +elif a[:2] == ["pane", "close"]: + spec.setdefault("agents", {})[server] = [x for x in spec.get("agents", {}).get(server, []) + if x.get("pane_id") != a[2]] + json.dump(spec, open(os.environ["FAKE_HERDR_SPEC"], "w")) ''' STUB_MONITOR = r'''#!/usr/bin/env bash @@ -366,6 +375,28 @@ def test_refuses_a_rowless_agent_on_any_running_server(box): assert '"down"' not in calls +def test_the_rowless_agent_hint_stops_it_on_the_server_it_was_found_on(box): + """R3: the printed stop command names the server; without it stop looks on + the workspace's server, where a same-named agent runs outside the workspace.""" + write_rows(box.ws, []) + box.herdr(sessions={"default": True, "unrecorded-server": True}, + agents={"unrecorded-server": [{"name": "stray", "pane_id": "w3:p1", "cwd": str(box.ws / "sub")}], + "default": [{"name": "stray", "pane_id": "w1:p1", "cwd": str(box.root / "elsewhere")}]}) + res = box.update() + assert res.returncode == 1, res.stdout + res.stderr + line = next(ln for ln in res.stderr.splitlines() if "herdr agent stray" in ln) + cmd = line.split("stop it: ", 1)[1] + assert "--herdr-session unrecorded-server" in cmd, cmd + stop = subprocess.run(["bash", "-c", cmd], cwd=str(box.ws), env=box.env, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=120) + assert stop.returncode == 0, stop.stdout + stop.stderr + agents = json.loads(box.spec.read_text())["agents"] + assert agents["unrecorded-server"] == [] + assert [x["pane_id"] for x in agents["default"]] == ["w1:p1"], "the same name elsewhere is left alone" + res = box.update() + assert res.returncode == 0, res.stdout + res.stderr + + def test_an_agent_outside_the_workspace_does_not_refuse(box): box.herdr(agents={"default": [{"name": "other", "pane_id": "w1:p2", "cwd": str(box.root / "elsewhere")}]}) res = box.update() @@ -406,6 +437,34 @@ def test_open_jobs_refuse_and_terminal_or_dry_run_do_not(box, tmp_path): del box.env["MAM_JOBS_DIR"] +@pytest.mark.skipif(os.geteuid() == 0, reason="root reads a mode-000 directory") +def test_a_jobs_directory_that_cannot_be_listed_refuses(box, tmp_path): + """R2: an unreadable .mam/jobs (or MAM_JOBS_DIR under an unreadable parent) + is not an empty registry.""" + jobs = box.mam / "jobs" + write_job(jobs, "hidden1", status="running") + before = snapshot(box.ws) + jobs.chmod(0) + try: + res = box.update() + finally: + jobs.chmod(0o755) + assert_refused_unchanged(box, before, res, "jobs directory %s cannot be listed" % jobs) + (jobs / "hidden1.json").unlink() + + parent = tmp_path / "locked" + write_job(parent / "jobs", "env1", status="running") + box.env["MAM_JOBS_DIR"] = str(parent / "jobs") + before = snapshot(box.ws) + parent.chmod(0) + try: + res = box.update() + finally: + parent.chmod(0o755) + assert_refused_unchanged(box, before, res, "jobs directory %s cannot be listed" % (parent / "jobs")) + del box.env["MAM_JOBS_DIR"] + + def test_other_control_planes_count_only_jobs_addressed_to_this_registry(box, tmp_path): other = tmp_path / "other-plane" / "jobs" write_job(other, "theirs", status="running", session="herdr:not-ours") -- 2.54.0 From 253a0aa56ee3e7f51602b65fddf2346662ff91e6 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 11:09:30 +0000 Subject: [PATCH 11/16] fix(delegate): admission covers a rowless target and the installed parent of an incidental .mam (Issue #72, review round 2) R1: a target with no registry row skipped the target gate. Its cwd is now read from the live agent on the server delivery uses (resolve_herdr_session), through the real herdr only when that server is running, so no server is started; a herdr that cannot answer refuses before registration. R2: any .mam counted as a workspace and stopped the walk, so B/sub/.mam hid an installed B and the row's control_plane root was dropped. Workspaces are now update.sh's installed trees (.mam_deploy/remove.sh or remove.sh): every installed ancestor of the cwd, plus the recorded control_plane root. lib_py.update target-workspaces prints them one per line; the wrapper holds each on fds 20.. until the record is written. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 2 +- .agents/skills/lib_py/update.py | 77 +++++++++++++------ .../multi-agent-mux-delegate-job | 24 ++++-- tests/test_issue72_admission.py | 58 ++++++++++---- 4 files changed, 115 insertions(+), 46 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 90d08ff..85be750 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3266,7 +3266,7 @@ mam_control_plane_prefix() { # # mam_update_admit [workspace [fd]]: rc 0 admitted (fd 7, or the given one, # held unless the workspace has no .mam), 1 refused (stderr). Delegate submit -# also takes its target's workspace on fd 8. +# also takes the workspaces its target is in on fds 20 and up. mam_update_admit() { local root="${1:-$WORKSPACE_ROOT}" fd="${2:-7}" local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st diff --git a/.agents/skills/lib_py/update.py b/.agents/skills/lib_py/update.py index 99fe496..a88796a 100644 --- a/.agents/skills/lib_py/update.py +++ b/.agents/skills/lib_py/update.py @@ -16,11 +16,13 @@ this module what needs parsing. Standard library only, Python 3.9. python -m lib_py.update monitor --workspace W JSON {"pids": [...], "lock": "free"|"held"}: the monitor daemons daemon.find_running recognises, and whether .mam/monitor.lock is held. - python -m lib_py.update target-workspace --workspace W --agent-session S - The installed workspace a delegate target `herdr:` belongs to - (decision 8), when it is not W: the nearest directory holding a .mam - at or above its row's pane.cwd, else the row's control_plane - workspace_root. Nothing for a rowless or non-herdr target. + python -m lib_py.update target-workspaces --workspace W --agent-session S --server V --herdr H + The workspaces other than W whose update admission a delegate submit + to `herdr:` must take (decision 8), one per line: every installed + workspace at or above the target's cwd (its row's pane.cwd; with no row + the live agent's on server V) and its row's control_plane + workspace_root. Nothing for a non-herdr target. rc 1: herdr cannot + answer for a rowless target. python -m lib_py.update backup-registry --workspace W --dest D Copies the registry into D under the registry's own write lock (agent-sessions.db.lock, as atomic_yaml takes it): the DB through the @@ -264,27 +266,46 @@ def precheck(workspace: str, state: dict, herdr: str, env: Mapping[str, str]) -> # --------------------------------------------------------------------------- delegate target -def target_workspace(workspace: str, state: dict, agent_session: str) -> str: +def _installed(d: str) -> bool: + """update.sh's own test of an installed workspace.""" + return os.path.isfile(os.path.join(d, ".mam_deploy", "remove.sh")) or os.path.isfile(os.path.join(d, "remove.sh")) + + +def target_workspaces(workspace: str, state: dict, agent_session: str, server: str, + herdr: str, env: Mapping[str, str]) -> List[str]: + """Every installed workspace at or above the target's cwd - an update of + any of them counts that agent (decision 1) - and its row's + control_plane.workspace_root; the caller's own workspace left out. With + no row the cwd is the live agent's on (the one delivery uses), + read with the real herdr. No herdr, a server that is not running, or no + such agent gives nothing (delivery then fails on its own); a herdr that + cannot answer raises HerdrError.""" if not agent_session.startswith("herdr:"): - return "" + return [] name = agent_session[len("herdr:"):] row = next((s for s in state.get("herdr_sessions") or [] if isinstance(s, dict) and s.get("name") == name), None) - if row is None: - return "" - found = "" - cwd = (row.get("pane") or {}).get("cwd") or "" - d = _real(cwd) if cwd else "" - while d and not found: - if os.path.isdir(os.path.join(d, ".mam")): - found = d - elif os.path.dirname(d) == d: - break - d = os.path.dirname(d) - if not found: + cwds, out = [], [] + if row is not None: + cwds.append((row.get("pane") or {}).get("cwd") or "") root = (row.get("control_plane") or {}).get("workspace_root") or "" - found = _real(root) if root else "" - return "" if found == _real(workspace) else found + if root: + out.append(_real(root)) + elif herdr and server in running_servers(herdr, env): + for a in server_agents(herdr, server, env): + if a.get("name") in (name, sanitize_herdr_agent_name(name)): + cwds += [a.get("cwd") or "", a.get("foreground_cwd") or ""] + break + for cwd in cwds: + d = _real(cwd) if cwd else "" + while d: + if _installed(d): + out.append(d) + if os.path.dirname(d) == d: + break + d = os.path.dirname(d) + me = _real(workspace) + return [d for i, d in enumerate(out) if d != me and d not in out[:i]] # --------------------------------------------------------------------------- monitor @@ -344,9 +365,11 @@ def main(argv: Optional[List[str]] = None) -> int: s.add_argument("--herdr", required=True) s = sub.add_parser("monitor") s.add_argument("--workspace", required=True) - s = sub.add_parser("target-workspace") + s = sub.add_parser("target-workspaces") s.add_argument("--workspace", required=True) s.add_argument("--agent-session", required=True) + s.add_argument("--server", default="default") + s.add_argument("--herdr", default="") s = sub.add_parser("backup-registry") s.add_argument("--workspace", required=True) s.add_argument("--dest", required=True) @@ -362,12 +385,18 @@ def main(argv: Optional[List[str]] = None) -> int: for line in lines: print(line) return 1 if lines else 0 - if a.cmd == "target-workspace": + if a.cmd == "target-workspaces": try: state = _state() except ValueError: state = {} - print(target_workspace(ws, state, a.agent_session)) + try: + roots = target_workspaces(ws, state, a.agent_session, a.server, a.herdr, os.environ) + except HerdrError as exc: + print("cannot find %s live: %s" % (a.agent_session, exc), file=sys.stderr) + return 1 + for root in roots: + print(root) return 0 if a.cmd == "monitor": print(json.dumps(monitor_state(ws))) diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index 1b1e488..b3e97f6 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -302,17 +302,24 @@ cmd_submit() { if [[ "$DRY_RUN" == "1" ]]; then reg_dry=(--dry-run); fi # Issue #72: no job is registered while an update of this registry's - # workspace or of the target's (its row's workspace, lib_py.update - # target-workspace) runs; held until the record is written (lib.sh + # workspace or of one the target is in (lib_py.update target-workspaces: + # its row's, or with no row the live agent's on the server delivery uses) + # runs; each is held, fd 7 then 20.., until the record is written (lib.sh # mam_update_admit). A dry-run record is never dispatched and is not # counted by the update. + local admit_fds=() target_ws tws fd=20 if [[ "$DRY_RUN" != "1" ]]; then - local target_ws target_ws="$(MAM_STATE_JSON="$(load_state_json)" PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.update \ - target-workspace --workspace "$WORKSPACE_ROOT" --agent-session "$AGENT_SESSION")" \ - || { echo "ERROR: cannot resolve the workspace of $AGENT_SESSION" >&2; exit 1; } + target-workspaces --workspace "$WORKSPACE_ROOT" --agent-session "$AGENT_SESSION" \ + --server "$(_is_remote "$AGENT_SESSION" || resolve_herdr_session "${AGENT_SESSION#herdr:}" "$WORKDIR" 2>/dev/null)" \ + --herdr "$(_resolve_real_herdr_path || true)")" \ + || { echo "ERROR: cannot resolve the workspace of $AGENT_SESSION; nothing was registered" >&2; exit 1; } mam_update_admit "$WORKSPACE_ROOT" || exit 1 - if [[ -n "$target_ws" ]]; then mam_update_admit "$target_ws" 8 || exit 1; fi + while IFS= read -r tws; do + [[ -n "$tws" ]] || continue + mam_update_admit "$tws" "$fd" || exit 1 + admit_fds+=("$fd"); fd=$((fd + 1)) + done <<< "$target_ws" fi # 1) register job (prints the new job id) JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \ @@ -323,7 +330,10 @@ cmd_submit() { --job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \ --max-iterations "$MAX_ITERATIONS" ${reg_parent[@]+"${reg_parent[@]}"} ${reg_dry[@]+"${reg_dry[@]}"})" echo "registered job: $JOB_ID" - [[ "$DRY_RUN" == "1" ]] || { mam_update_admit_release; mam_update_admit_release 8; } + if [[ "$DRY_RUN" != "1" ]]; then + mam_update_admit_release + for fd in ${admit_fds[@]+"${admit_fds[@]}"}; do mam_update_admit_release "$fd"; done + fi # 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring) local job_dir="$REGISTRY_DIR/$JOB_ID" diff --git a/tests/test_issue72_admission.py b/tests/test_issue72_admission.py index a8a395f..0e573c1 100644 --- a/tests/test_issue72_admission.py +++ b/tests/test_issue72_admission.py @@ -146,9 +146,12 @@ def test_delegate_submit_registers_no_job(mam_sandbox, mock_herdr, marker): @pytest.fixture def other_ws(tmp_path): - """A second installed workspace B, outside the sandbox (A).""" + """A second installed workspace B (update.sh's own test: .mam_deploy/remove.sh), + outside the sandbox (A).""" b = Path(os.path.realpath(tmp_path)) / "ws-b" (b / ".mam").mkdir(parents=True) + (b / ".mam_deploy").mkdir() + (b / ".mam_deploy" / "remove.sh").write_text("#!/bin/sh\n") (b / "sub").mkdir() return b @@ -163,27 +166,54 @@ def _no_job(sandbox): return not jobs.exists() or not list(jobs.glob("*.json")) -@pytest.mark.parametrize("binding", ["pane-cwd", "control-plane"]) -def test_delegate_submit_takes_the_target_workspace_marker(mam_sandbox, mock_herdr, marker, other_ws, binding): - """R1: the target row's workspace (the .mam at or above its pane.cwd, else its - control_plane.workspace_root) is checked, not only the caller's.""" +def _server_for(sandbox, name): + """The server the wrapper delivers a rowless target on (resolve_herdr_session).""" + res = subprocess.run(["bash", "-c", 'source "$1" && resolve_herdr_session "$2" "$3"', "_", + str(sandbox / ".agents" / "skills" / "lib.sh"), name, str(sandbox)], + capture_output=True, text=True, cwd=str(sandbox)) + assert res.returncode == 0 and res.stdout.strip(), res.stderr + return res.stdout.strip() + + +def _target_in(kind, sandbox, mock_herdr, b): + """Put the delegate target `tgt` in workspace B as ; the env for submit.""" + if kind == "rowless-live": + _set_mock(mock_herdr, agents={"tgt": _agent(b / "sub", "w1:p6")}) + return {"MAM_SESS": _server_for(sandbox, "tgt")} r = _row("tgt") - if binding == "pane-cwd": - r["pane"]["cwd"] = str(other_ws / "sub") - else: + if kind == "row-pane-cwd": + r["pane"]["cwd"] = str(b / "sub") + elif kind == "row-control-plane": r["pane"]["cwd"] = "/nonexistent/tgt" - r["control_plane"] = {"workspace_root": str(other_ws)} - _seed(mam_sandbox, [r]) + r["control_plane"] = {"workspace_root": str(b)} + elif kind == "row-under-incidental-mam": + (b / "sub" / ".mam").mkdir() # runtime state only: not an installed tree + r["pane"]["cwd"] = str(b / "sub") + r["control_plane"] = {"workspace_root": str(b)} + _seed(sandbox, [r]) + return {} + + +KINDS = ["row-pane-cwd", "row-control-plane", "row-under-incidental-mam", "rowless-live"] + + +@pytest.mark.parametrize("kind", KINDS) +def test_delegate_submit_takes_the_target_workspace_marker(mam_sandbox, mock_herdr, marker, other_ws, kind): + """R1 (rounds 1, 2): every installed workspace the target is in - by its row, + or for a rowless target by the live agent's cwd - is checked, not only the + caller's; an incidental .mam below B does not hide B.""" + env = _target_in(kind, mam_sandbox, mock_herdr, other_ws) marker(True, other_ws) - res = _submit_to(mam_sandbox, "herdr:tgt") + res = _submit_to(mam_sandbox, "herdr:tgt", env=env) assert res.returncode != 0, res.stdout + res.stderr assert "an update of %s is in progress" % other_ws in res.stderr, res.stdout + res.stderr assert "registered job" not in res.stdout assert _no_job(mam_sandbox) -def test_delegate_submit_waits_for_the_target_workspace_admission_lock(mam_sandbox, mock_herdr, other_ws): - _seed(mam_sandbox, [dict(_row("tgt"), pane={"cwd": str(other_ws), "cmd": "claude"})]) +@pytest.mark.parametrize("kind", KINDS) +def test_delegate_submit_waits_for_the_target_workspace_admission_lock(mam_sandbox, mock_herdr, other_ws, kind): + env = _target_in(kind, mam_sandbox, mock_herdr, other_ws) holder = subprocess.Popen([sys.executable, "-c", "import fcntl, sys, time\n" "f = open(sys.argv[1], 'a'); fcntl.flock(f, fcntl.LOCK_EX)\n" "print('locked', flush=True); time.sleep(120)", @@ -191,7 +221,7 @@ def test_delegate_submit_waits_for_the_target_workspace_admission_lock(mam_sandb stdout=subprocess.PIPE, text=True, start_new_session=True) try: assert holder.stdout.readline().strip() == "locked" - res = _submit_to(mam_sandbox, "herdr:tgt", env={"MAM_UPDATE_ADMISSION_WAIT_S": "1"}) + res = _submit_to(mam_sandbox, "herdr:tgt", env=dict(env, MAM_UPDATE_ADMISSION_WAIT_S="1")) finally: holder.kill() holder.wait() -- 2.54.0 From 454b77215f2a0eee683ebf6822dbe89fb5188e11 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 27 Oct 2026 14:35:35 +0000 Subject: [PATCH 12/16] fix(update): a state write failure before the install restarts the monitor; the exit table names the conversion code (Issue #72, review notes) install_phase.sh exited when it could not mark the backup `installing`, before the installer ran, without starting the monitor phase 1 had stopped (decision 2) and leaving the backup behind. Nothing was changed at that point, so it now removes the backup, restarts the monitor if it ran, releases the marker and exits 1. SKILL.md's exit row said 1 for every failure; a failed registry conversion after a good install passes the converter's nonzero code through, and a cancelled prompt exits 0. Co-Authored-By: Claude Opus 5.5 --- .../skills/multi-agent-mux-update/SKILL.md | 2 +- .../scripts/install_phase.sh | 9 ++++++++- tests/test_issue72_update.py | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.agents/skills/multi-agent-mux-update/SKILL.md b/.agents/skills/multi-agent-mux-update/SKILL.md index c322b63..a1eaff2 100644 --- a/.agents/skills/multi-agent-mux-update/SKILL.md +++ b/.agents/skills/multi-agent-mux-update/SKILL.md @@ -27,4 +27,4 @@ bash .agents/skills/multi-agent-mux-update/scripts/update.sh --yes | Monitor | stopped first, started again afterwards only if it ran (`MAM_NO_SUBSCRIBER_DAEMON=1`: not started). | | Install | the installer at `MAM_INSTALLER_URL` (default: Gitea `main`) runs in place; `.mam` is kept. On failure `.agents/`, `.mam_deploy/`, root files, `.venv`, the installer's `.mam` files and the registry are restored from `.mam/update-backup/`. | | Registry | converted by the new code (`lib_py.registry_format`); a failed conversion keeps the install and exits nonzero. | -| Exit | `0` updated, `1` refused or failed, `2` bad arguments. | +| Exit | `0` updated (or cancelled at the prompt), `1` refused or failed, `2` bad arguments; a failed registry conversion after a good install exits with the converter's nonzero code. | diff --git a/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh b/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh index 5741ffb..78a6561 100755 --- a/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh +++ b/.agents/skills/multi-agent-mux-update/scripts/install_phase.sh @@ -81,7 +81,14 @@ MON="${3:-0}" TMPD="${4:-}" trap '[ -z "$TMPD" ] || rm -rf "$TMPD"' EXIT -set_state installing || { echo "ERROR: cannot write $BK/state" >&2; release_marker; exit 1; } +if ! set_state installing; then + # Nothing was installed yet: drop the backup and put the monitor back as phase 1 found it. + echo "ERROR: cannot write $BK/state; nothing was changed" >&2 + rm -rf "$BK" + restart_old_monitor "$MON" + release_marker + exit 1 +fi URL="${MAM_INSTALLER_URL:-https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh}" echo "install: $URL" irc=0 diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py index 2d3284f..552ed83 100644 --- a/tests/test_issue72_update.py +++ b/tests/test_issue72_update.py @@ -702,6 +702,26 @@ def test_leftover_whose_restore_fails_is_kept_and_nothing_more_changes(box): assert not (box.mam / "update-in-progress").exists() +def test_a_state_write_failure_before_the_install_changes_nothing_and_restarts_the_monitor(box): + real_mv = shutil.which("mv") + fake = box.bin / "mv" + fake.write_text("#!/usr/bin/env bash\n" + "src=\"${@: -2:1}\"\n" + "case \"$src\" in */update-backup/state.tmp) grep -qx installing \"$src\" && exit 1 ;; esac\n" + "exec %s \"$@\"\n" % real_mv) + fake.chmod(0o755) + box.stub_monitor() + box.start_monitor() + box.installer("touch \"$ws/INSTALLER-RAN\"\n") + res = box.update() + assert res.returncode == 1, res.stdout + res.stderr + assert not (box.ws / "INSTALLER-RAN").exists() + assert box.monitor_starts() == 2, "the monitor it stopped is started again" + _until(lambda: bool(box.monitor_pids())) + assert not (box.mam / "update-backup").exists() and not (box.mam / "update-in-progress").exists() + assert "nothing was changed" in res.stderr + + def test_the_backup_lives_in_the_workspace_and_is_gone_after_success(box): box.installer("test -f \"$ws/.mam/update-backup/state\" && grep -qx installing \"$ws/.mam/update-backup/state\" " "&& test -f \"$ws/.mam/update-backup/files/.agents/skills/lib.sh\" && touch \"$ws/../saw-backup\"\n") -- 2.54.0 From cf1b693e854aeb9bd98f2d6195e871e4ef9ce34f Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 28 Oct 2026 02:28:55 +0900 Subject: [PATCH 13/16] fix(update): the monitor is this workspace's exact command line; create/resume take the pane workspace's admission; refusal snapshots compare the registry by rows (Issue #72, review round 3) - daemon.find_running matches ` --subscribe[ ...]` exactly instead of an unanchored, unescaped pgrep regex: another workspace's daemon (ws.a vs wsXa) or a process naming the path in an argument is no longer signalled by the update. update.sh re-checks which PIDs are still the monitor before SIGKILL. - create and resume (spawn path) take the update admission of every installed workspace at or above the pane cwd, not only the control root's (lib.sh mam_update_admit_at, lib_py.update target-workspaces --cwd); the ~/.local/bin wrapper child drops all of them. - tests: snapshot() skips the SQLite -wal/-shm and compares the DB by its rows (macOS keeps the sidecars and a read rewrites -shm); regression tests for the monitor match and for create/resume in another installed workspace (marker and lock wait); evidence line refs shifted. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 33 +++++++++- .agents/skills/lib_py/delegation/daemon.py | 33 +++++++--- .agents/skills/lib_py/update.py | 22 ++++++- .../scripts/create_session.sh | 11 ++-- .../scripts/resume_session.sh | 9 +-- .../multi-agent-mux-update/scripts/update.sh | 8 +++ tests/data/skill_code_blocks.json | 20 +++---- tests/test_issue72_admission.py | 45 ++++++++++++++ tests/test_issue72_update.py | 60 ++++++++++++++++++- 9 files changed, 209 insertions(+), 32 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 85be750..7b862ff 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -3266,7 +3266,8 @@ mam_control_plane_prefix() { # # mam_update_admit [workspace [fd]]: rc 0 admitted (fd 7, or the given one, # held unless the workspace has no .mam), 1 refused (stderr). Delegate submit -# also takes the workspaces its target is in on fds 20 and up. +# also takes the workspaces its target is in on fds 20 and up, create and +# resume those their pane's cwd is in (mam_update_admit_at). mam_update_admit() { local root="${1:-$WORKSPACE_ROOT}" fd="${2:-7}" local lock="$root/.mam/update-admission.lock" marker="$root/.mam/update-in-progress" st @@ -3305,3 +3306,33 @@ mam_update_admit() { mam_update_admit_release() { eval "exec ${1:-7}>&-" } + +# mam_update_admit_at : what create and resume take for an agent they +# start in - mam_update_admit for the control root (fd 7) and for every +# other installed workspace at or above (fds 20 and up), whose update +# counts that agent (decision 1). rc 1 refused, nothing held. +# mam_update_admit_release_all drops them all; a long-lived child started +# while they are held runs it first. +MAM_UPDATE_ADMIT_FDS="" +mam_update_admit_at() { + local roots root fd=20 + roots="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.update target-workspaces \ + --workspace "$WORKSPACE_ROOT" --cwd "$1")" \ + || { echo "ERROR: cannot resolve the installed workspaces at or above $1" >&2; return 1; } + mam_update_admit || return 1 + while IFS= read -r root; do + [ -n "$root" ] || continue + if ! mam_update_admit "$root" "$fd"; then + mam_update_admit_release_all + return 1 + fi + MAM_UPDATE_ADMIT_FDS="$MAM_UPDATE_ADMIT_FDS $fd"; fd=$((fd + 1)) + done <<< "$roots" +} + +mam_update_admit_release_all() { + local fd + mam_update_admit_release + for fd in $MAM_UPDATE_ADMIT_FDS; do mam_update_admit_release "$fd"; done + MAM_UPDATE_ADMIT_FDS="" +} diff --git a/.agents/skills/lib_py/delegation/daemon.py b/.agents/skills/lib_py/delegation/daemon.py index 630ee9d..96e51de 100644 --- a/.agents/skills/lib_py/delegation/daemon.py +++ b/.agents/skills/lib_py/delegation/daemon.py @@ -2,7 +2,8 @@ Behaviour is byte-for-byte compatible with the shell original (issue #5 phase 1, D5): same monitor path, same stderr wording and exit status when it is missing, -same `pgrep -f` pre-check pattern, same log file, same "print the PID" contract. +the same daemons found by the pre-check (matched exactly since Issue #72, see +find_running), same log file, same "print the PID" contract. Callers capture stdout with $( ), so nothing else may be printed there. """ from __future__ import annotations @@ -39,20 +40,34 @@ def monitor_script_path(workdir: str) -> str: def find_running(monitor_script: str) -> List[str]: """PIDs of subscribers already running for this monitor script. - Uses the exact `pgrep -f` pattern the shell helper used, so a daemon started - by either implementation is found by the other. Multiple matches are returned - in full: the legacy helper echoed every line and callers only ever echo it - back, so trimming here would be a silent behaviour change. + A subscriber is a process whose command line is ` + --subscribe[ ...]`, argv0 a bash, as both this module and the shell helper + start it. Issue #72: matched exactly, not as the shell helper's `pgrep -f` + regex, because the update skill signals what this returns - a daemon of + another workspace the unescaped path also matched, or a process that only + names the path in an argument, is not this workspace's monitor. Multiple + matches are returned in full: the legacy helper echoed every line and + callers only ever echo it back, so trimming here would be a silent + behaviour change. """ try: res = subprocess.run( - ["pgrep", "-f", "bash {} --subscribe".format(monitor_script)], + ["ps", "-A", "-ww", "-o", "pid=", "-o", "args="], capture_output=True, text=True, ) - except OSError as exc: # pragma: no cover - pgrep missing - logger.warning("pgrep unavailable: %s", exc) + except OSError as exc: # pragma: no cover - ps missing + logger.warning("ps unavailable: %s", exc) return [] - return [line for line in res.stdout.split() if line.strip()] + want = "{} --subscribe".format(monitor_script) + pids = [] + for line in res.stdout.splitlines(): + parts = line.strip().split(" ", 1) + if len(parts) != 2: + continue + argv0, _, rest = parts[1].partition(" ") + if os.path.basename(argv0) == "bash" and (rest == want or rest.startswith(want + " ")): + pids.append(parts[0]) + return pids def _ignore_sighup(): # pragma: no cover - runs in the forked child diff --git a/.agents/skills/lib_py/update.py b/.agents/skills/lib_py/update.py index a88796a..daa815c 100644 --- a/.agents/skills/lib_py/update.py +++ b/.agents/skills/lib_py/update.py @@ -23,6 +23,9 @@ this module what needs parsing. Standard library only, Python 3.9. the live agent's on server V) and its row's control_plane workspace_root. Nothing for a non-herdr target. rc 1: herdr cannot answer for a rowless target. + python -m lib_py.update target-workspaces --workspace W --cwd C + The same for an agent create or resume starts in C: every installed + workspace at or above C other than W. python -m lib_py.update backup-registry --workspace W --dest D Copies the registry into D under the registry's own write lock (agent-sessions.db.lock, as atomic_yaml takes it): the DB through the @@ -296,6 +299,12 @@ def target_workspaces(workspace: str, state: dict, agent_session: str, server: s if a.get("name") in (name, sanitize_herdr_agent_name(name)): cwds += [a.get("cwd") or "", a.get("foreground_cwd") or ""] break + return _others(workspace, out + installed_above(cwds)) + + +def installed_above(cwds: Iterable[str]) -> List[str]: + """Every installed workspace at or above each cwd.""" + out = [] for cwd in cwds: d = _real(cwd) if cwd else "" while d: @@ -304,8 +313,12 @@ def target_workspaces(workspace: str, state: dict, agent_session: str, server: s if os.path.dirname(d) == d: break d = os.path.dirname(d) + return out + + +def _others(workspace: str, dirs: List[str]) -> List[str]: me = _real(workspace) - return [d for i, d in enumerate(out) if d != me and d not in out[:i]] + return [d for i, d in enumerate(dirs) if d != me and d not in dirs[:i]] # --------------------------------------------------------------------------- monitor @@ -367,7 +380,8 @@ def main(argv: Optional[List[str]] = None) -> int: s.add_argument("--workspace", required=True) s = sub.add_parser("target-workspaces") s.add_argument("--workspace", required=True) - s.add_argument("--agent-session", required=True) + s.add_argument("--agent-session", default="") + s.add_argument("--cwd", default="") s.add_argument("--server", default="default") s.add_argument("--herdr", default="") s = sub.add_parser("backup-registry") @@ -385,6 +399,10 @@ def main(argv: Optional[List[str]] = None) -> int: for line in lines: print(line) return 1 if lines else 0 + if a.cmd == "target-workspaces" and a.cwd: + for root in _others(ws, installed_above([a.cwd])): + print(root) + return 0 if a.cmd == "target-workspaces": try: state = _state() diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 5899963..daac476 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -260,7 +260,7 @@ spawn() { SESSION_UUID="" CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")" [ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2 - nohup "$WRAPPER" >/dev/null 2>&1 7>&- & + ( mam_update_admit_release_all; exec nohup "$WRAPPER" >/dev/null 2>&1 ) & disown else HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" @@ -285,9 +285,10 @@ if [ "$DRY_RUN" = "1" ]; then fi # Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait). mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1 -# Issue #72: no new session while an update runs; held until the row is written -# (lib.sh mam_update_admit). -mam_update_admit || exit 1 +# Issue #72: no new session while an update of the control root or of an +# installed workspace the pane's cwd is in runs; held until the row is written +# (lib.sh mam_update_admit_at). +mam_update_admit_at "$WORKSPACE" || exit 1 HERDR_EPOCH=$(date +%s) NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') spawn @@ -641,7 +642,7 @@ snap['cwd'] = os.environ['PANE_CWD'] print(f"appended: {name}", flush=True) PYEOF ROW_APPENDED=1 -mam_update_admit_release +mam_update_admit_release_all echo echo "=== created ===" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 0668256..df2ca1c 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -254,9 +254,10 @@ if [ "${DRY_RUN:-0}" = "1" ]; then exit 0 fi -# Issue #72: no new session while an update runs; held until the row is written -# (lib.sh mam_update_admit). -mam_update_admit || exit 1 +# Issue #72: no new session while an update of the control root or of an +# installed workspace the pane's cwd is in runs; held until the row is written +# (lib.sh mam_update_admit_at). +mam_update_admit_at "$WORKSPACE" || exit 1 # Issue #63: wait if another same-cwd session of this post-discovered agent # is still being created or has not recorded its id yet (lib.sh mam_id_order_wait). @@ -294,7 +295,7 @@ CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_r --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${DROP_FLAG:+$DROP_FLAG} \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} -mam_update_admit_release +mam_update_admit_release_all _ensure_monitor_daemon echo "Successfully resumed $SESSION_NAME ($AGENT)" diff --git a/.agents/skills/multi-agent-mux-update/scripts/update.sh b/.agents/skills/multi-agent-mux-update/scripts/update.sh index 652f331..60da74e 100755 --- a/.agents/skills/multi-agent-mux-update/scripts/update.sh +++ b/.agents/skills/multi-agent-mux-update/scripts/update.sh @@ -185,6 +185,14 @@ if [ -n "$MON_PIDS" ]; then echo "monitor: stopping the daemon (PID $MON_PIDS)" kill -TERM "$@" 2>/dev/null || true if ! _wait_monitor_gone "$(mam_int_knob MAM_UPDATE_MONITOR_STOP_S 30 1)" "$@"; then + # SIGKILL only what is still this workspace's monitor: a PID that ended + # during the wait may already be another process's. + still="" + for p in $(_monitor_json | _json_field pids); do + case " $* " in *" $p "*) still="$still $p" ;; esac + done + # shellcheck disable=SC2086 + set -- $still for p in "$@"; do kids="$kids $(pgrep -P "$p" 2>/dev/null | tr '\n' ' ' || true)"; done echo "WARNING: the monitor did not stop within MAM_UPDATE_MONITOR_STOP_S; sending SIGKILL to $* $kids" >&2 # shellcheck disable=SC2086 diff --git a/tests/data/skill_code_blocks.json b/tests/data/skill_code_blocks.json index b18ff47..0d4a81d 100644 --- a/tests/data/skill_code_blocks.json +++ b/tests/data/skill_code_blocks.json @@ -11,7 +11,7 @@ "kind": "example", "evidence": [ "multi-agent-mux-create/scripts/create_session.sh:99", - "multi-agent-mux-create/scripts/create_session.sh:359" + "multi-agent-mux-create/scripts/create_session.sh:360" ], "mismatch": null, "note": "HERDR_SESSION_NAME environment example" @@ -67,7 +67,7 @@ "class": "c", "kind": "example", "evidence": [ - "multi-agent-mux-create/scripts/create_session.sh:517-544" + "multi-agent-mux-create/scripts/create_session.sh:518-545" ], "mismatch": null, "note": "Output format: the row create_session.sh writes" @@ -492,9 +492,9 @@ "multi-agent-mux-resume/scripts/resume_session.sh:133-144", "multi-agent-mux-resume/scripts/resume_session.sh:147-170", "multi-agent-mux-resume/scripts/resume_session.sh:188-213", - "multi-agent-mux-resume/scripts/resume_session.sh:265", - "multi-agent-mux-resume/scripts/resume_session.sh:269", - "multi-agent-mux-resume/scripts/resume_session.sh:275-278", + "multi-agent-mux-resume/scripts/resume_session.sh:266", + "multi-agent-mux-resume/scripts/resume_session.sh:270", + "multi-agent-mux-resume/scripts/resume_session.sh:276-279", "lib_py/agents/adapters/claude.py:109" ], "mismatch": [ @@ -561,8 +561,8 @@ "multi-agent-mux-create/scripts/create_session.sh:166-168", "multi-agent-mux-create/scripts/create_session.sh:207", "multi-agent-mux-create/scripts/create_session.sh:259-274", - "multi-agent-mux-create/scripts/create_session.sh:364-401", - "multi-agent-mux-create/scripts/create_session.sh:413-415" + "multi-agent-mux-create/scripts/create_session.sh:365-402", + "multi-agent-mux-create/scripts/create_session.sh:414-416" ], "mismatch": [ "C1: an existing session is exit 1 in the doc, exit 3 in the script", @@ -582,7 +582,7 @@ "base_sha": "f71be0f26fb8", "class": "a", "evidence": [ - "multi-agent-mux-create/scripts/create_session.sh:489-607" + "multi-agent-mux-create/scripts/create_session.sh:490-608" ], "mismatch": [], "replacement": "bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace --agent --role ", @@ -633,14 +633,14 @@ "other" ], "passthrough": { - "4": "multi-agent-mux-create/scripts/create_session.sh:549" + "4": "multi-agent-mux-create/scripts/create_session.sh:550" } } }, "prose_mismatches": [ "C4: create named a nonexistent `agent-sessions-yaml-edit` script; removed (create_session.sh writes the row)", "C5: create's cmd_full table (claude = `claude`) disagreed with adapter spawn-spec; replaced by \"decided by spawn-spec\"", - "C6: create_session.sh header lists exit 4 \"YAML append failure\"; there is no literal `exit 4`, but the YAML write raises SystemExit(4) for a duplicate running row (create_session.sh:515) and it passes through, so 4 IS a real exit code (the plan assumed it was not)", + "C6: create_session.sh header lists exit 4 \"YAML append failure\"; there is no literal `exit 4`, but the YAML write raises SystemExit(4) for a duplicate running row (create_session.sh:516) and it passes through, so 4 IS a real exit code (the plan assumed it was not)", "R6: resume's \"The script above re-passes them\" and \"as done in Workflow above\" pointed at the removed block; reworded (same for create's Verification note)", "R7 (review 08e1f1b2): stop's table said exit 1 is always pre-stop; the registry update after the kill chain also raises SystemExit(1) (stop_session.sh:310) when the row disappeared" ], diff --git a/tests/test_issue72_admission.py b/tests/test_issue72_admission.py index 0e573c1..47d7322 100644 --- a/tests/test_issue72_admission.py +++ b/tests/test_issue72_admission.py @@ -230,6 +230,51 @@ def test_delegate_submit_waits_for_the_target_workspace_admission_lock(mam_sandb assert _no_job(mam_sandbox) +def _start_in(sandbox, entry, b, env=None): + """create or resume (spawn path) of an agent whose pane cwd is B/sub.""" + if entry == "create": + argv = ["bash", _script(sandbox, "multi-agent-mux-create/scripts/create_session.sh"), + "--workspace", str(b / "sub"), "--agent", "claude", "--role", "creator", + "--session", "gated-in-b", "--no-onboard"] + else: + _seed(sandbox, [dict(_row("gated-in-b", status="stopped", agent="agy"), + pane={"cwd": str(b / "sub"), "cmd": "agy"})]) + argv = ["bash", _script(sandbox, "multi-agent-mux-resume/scripts/resume_session.sh"), + "--workspace", str(b / "sub"), "--agent", "agy", "--session", "gated-in-b"] + return _run(sandbox, argv, env=env, timeout=300) + + +@pytest.mark.parametrize("entry", ["create", "resume"]) +def test_create_and_resume_take_the_pane_workspace_marker(mam_sandbox, mock_herdr, mock_agents, marker, + other_ws, entry): + """An agent started in an installed workspace B other than the control root + is one B's update counts, so B's marker refuses it too.""" + marker(True, other_ws) + res = _start_in(mam_sandbox, entry, other_ws) + assert res.returncode != 0, res.stdout + res.stderr + assert "an update of %s is in progress" % other_ws in res.stderr, res.stdout + res.stderr + assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned" + + +@pytest.mark.parametrize("entry", ["create", "resume"]) +def test_create_and_resume_wait_for_the_pane_workspace_admission_lock(mam_sandbox, mock_herdr, mock_agents, + other_ws, entry): + holder = subprocess.Popen([sys.executable, "-c", "import fcntl, sys, time\n" + "f = open(sys.argv[1], 'a'); fcntl.flock(f, fcntl.LOCK_EX)\n" + "print('locked', flush=True); time.sleep(120)", + str(other_ws / ".mam" / "update-admission.lock")], + stdout=subprocess.PIPE, text=True, start_new_session=True) + try: + assert holder.stdout.readline().strip() == "locked" + res = _start_in(mam_sandbox, entry, other_ws, env={"MAM_UPDATE_ADMISSION_WAIT_S": "1"}) + finally: + holder.kill() + holder.wait() + assert res.returncode != 0, res.stdout + res.stderr + assert "an update of %s holds its admission lock" % other_ws in res.stderr, res.stdout + res.stderr + assert json.loads(Path(mock_herdr).read_text())["agents"] == {}, "nothing was spawned" + + @pytest.mark.parametrize("purge", [False, True]) def test_stop_refuses_and_changes_nothing(mam_sandbox, mock_herdr, marker, purge): _seed(mam_sandbox, [_row("gated-stop")]) diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py index 552ed83..dfcd00f 100644 --- a/tests/test_issue72_update.py +++ b/tests/test_issue72_update.py @@ -199,7 +199,22 @@ def box(template, tmp_path): # --- helpers --------------------------------------------------------------------- -SKIP_SNAPSHOT = ("shim/", "update-admission.lock", "agent-sessions.db.lock", "monitor.lock", "stub-monitor.log") +SKIP_SNAPSHOT = ("shim/", "update-admission.lock", "agent-sessions.db.lock", "monitor.lock", "stub-monitor.log", + "agent-sessions.db-wal", "agent-sessions.db-shm") + + +def _db_content(p: Path) -> str: + """The registry DB by its rows: on macOS SQLite keeps -wal/-shm after close and + a read rewrites -shm, and a checkpoint moves the same rows between -wal and + the file, so neither file's bytes say whether the data changed. A file that + is not a database is compared by its bytes.""" + conn = sqlite3.connect(str(p)) + try: + return "DB:" + hashlib.sha256("\n".join(conn.iterdump()).encode()).hexdigest() + except sqlite3.DatabaseError: + return hashlib.sha256(p.read_bytes()).hexdigest() + finally: + conn.close() def snapshot(ws: Path) -> dict: @@ -211,6 +226,9 @@ def snapshot(ws: Path) -> dict: rel = str(p.relative_to(ws)) if rel.startswith(".mam/") and any(s in rel for s in SKIP_SNAPSHOT): continue + if rel == ".mam/agent-sessions.db": + out[rel] = _db_content(p) + continue out[rel] = "L:" + os.readlink(p) if p.is_symlink() else hashlib.sha256(p.read_bytes()).hexdigest() return out @@ -585,6 +603,46 @@ def test_a_monitor_lock_held_by_an_unknown_process_refuses(box): assert_refused_unchanged(box, before, res, "monitor.lock is held by a monitor the skills did not start") +def test_only_this_workspaces_exact_monitor_command_line_is_its_monitor(tmp_path): + """The update signals what `lib_py.update monitor` returns: `bash --subscribe ...` only - not another workspace's + daemon the path matched as a regex (ws.a, wsXa), nor a process that names + the path in an argument. A path with a space is still found.""" + root = Path(os.path.realpath(tmp_path)) + procs = [] + + def daemon_in(ws): + script = ws / MONITOR_REL + script.parent.mkdir(parents=True) + script.write_text("sleep 120 & wait $!\n") + procs.append(subprocess.Popen(["bash", str(script), "--subscribe", "--idle-timeout", "0"], + start_new_session=True)) + return str(procs[-1].pid) + + def monitor(ws): + res = subprocess.run([sys.executable, "-m", "lib_py.update", "monitor", "--workspace", str(ws)], + cwd=str(REPO_ROOT / ".agents" / "skills"), capture_output=True, text=True) + assert res.returncode == 0, res.stderr + return json.loads(res.stdout)["pids"] + + try: + a, other, spaced = root / "ws.a", root / "wsXa", root / "w s" + a.mkdir() + daemon_in(other) + procs.append(subprocess.Popen(["/bin/bash", "-c", "sleep 120 & wait $!", + "bash %s --subscribe" % (a / MONITOR_REL)], start_new_session=True)) + pid = daemon_in(spaced) + _until(lambda: monitor(spaced) == [pid]) + assert monitor(a) == [] + finally: + for p in procs: + try: + os.killpg(p.pid, signal.SIGKILL) + except OSError: + pass + p.wait() + + # --- rollback (decisions 3, 10; U4) -------------------------------------------------- FAILING_INSTALL = """ -- 2.54.0 From 1d63e882bc1c46e9af8484647d5091b296d8fa3f Mon Sep 17 00:00:00 2001 From: Godopu Date: Wed, 28 Oct 2026 04:10:15 +0900 Subject: [PATCH 14/16] fix(update): the legacy start_watchdog matches the monitor exactly too; a restart is reported only when the monitor runs (Issue #72, review round 4) With MAM_DELEGATION_PY=0 the legacy helper still found the monitor with an unanchored pgrep -f, so a process that only named the monitor path was reported as the restarted monitor and none was started. lib.sh _monitor_pids is the shell twin of daemon.find_running; update.sh and post_update.sh check the PID start_watchdog printed with mam_monitor_started before saying "monitor: restarted". Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib.sh | 37 +++++++++- .agents/skills/lib_py/delegation/daemon.py | 4 +- .../scripts/post_update.sh | 3 +- .../multi-agent-mux-update/scripts/update.sh | 2 + tests/test_issue72_update.py | 69 ++++++++++++++++--- 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 7b862ff..8f8375c 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2235,6 +2235,41 @@ _delegate_publish_event_legacy() { "$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true } +# _monitor_pids +# Issue #72: PIDs whose command line is exactly ` +# --subscribe[ ...]` - the shell twin of lib_py.delegation.daemon.find_running, +# so both start_watchdog branches recognise the same daemons. Not a `pgrep -f` +# regex: that also matched another workspace's daemon (an unescaped `.`) and a +# process that only names the path in an argument. +_monitor_pids() { + ps -A -ww -o pid= -o args= | MAM_MONITOR_WANT="$1 --subscribe" awk ' + { line = $0; sub(/^ +/, "", line) + i = index(line, " "); if (!i) next + pid = substr(line, 1, i - 1); args = substr(line, i + 1) + j = index(args, " "); if (!j) next + n = split(substr(args, 1, j - 1), a0, "/"); rest = substr(args, j + 1) + want = ENVIRON["MAM_MONITOR_WANT"] + if (a0[n] == "bash" && (rest == want || index(rest, want " ") == 1)) print pid }' +} + +# mam_monitor_started +# Issue #72: 0 once one of the PIDs start_watchdog printed is WORKSPACE's +# monitor (_monitor_pids), so a restart is only reported when it happened. +# Polled for up to 5 s: a daemon just started from the shell carries its +# parent's command line until it execs bash. +mam_monitor_started() { + local script="$1/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" n=0 p + shift + while [ "$n" -lt 50 ]; do + for p in $(_monitor_pids "$script"); do + case " $* " in *" $p "*) return 0 ;; esac + done + sleep 0.1 + n=$((n + 1)) + done + return 1 +} + # start_watchdog [workdir] # Spawns a watchdog process to monitor a delegate-job JOB in the background. # The watchdog re-spawns the subscriber every 2 minutes (or whatever hard @@ -2253,7 +2288,7 @@ _start_watchdog_legacy() { # Check if reconcile.sh --subscribe is already running on this workspace local pid - pid=$(pgrep -f "bash $monitor_script --subscribe" || true) + pid=$(_monitor_pids "$monitor_script" || true) # Issue #19: MAM_NO_SUBSCRIBER_DAEMON=1 reports a running daemon but never # starts one (test sandboxes; externally supervised monitors). diff --git a/.agents/skills/lib_py/delegation/daemon.py b/.agents/skills/lib_py/delegation/daemon.py index 96e51de..f84f1b2 100644 --- a/.agents/skills/lib_py/delegation/daemon.py +++ b/.agents/skills/lib_py/delegation/daemon.py @@ -42,8 +42,8 @@ def find_running(monitor_script: str) -> List[str]: A subscriber is a process whose command line is ` --subscribe[ ...]`, argv0 a bash, as both this module and the shell helper - start it. Issue #72: matched exactly, not as the shell helper's `pgrep -f` - regex, because the update skill signals what this returns - a daemon of + start it. Issue #72: matched exactly (lib.sh _monitor_pids is the shell + twin the legacy helper uses), not as a `pgrep -f` regex, because the update skill signals what this returns - a daemon of another workspace the unescaped path also matched, or a process that only names the path in an argument, is not this workspace's monitor. Multiple matches are returned in full: the legacy helper echoed every line and diff --git a/.agents/skills/multi-agent-mux-update/scripts/post_update.sh b/.agents/skills/multi-agent-mux-update/scripts/post_update.sh index 5d4ef7b..8fe6f14 100755 --- a/.agents/skills/multi-agent-mux-update/scripts/post_update.sh +++ b/.agents/skills/multi-agent-mux-update/scripts/post_update.sh @@ -37,7 +37,8 @@ fi if [ "$MON" = "1" ]; then if [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then echo "monitor: not restarted (MAM_NO_SUBSCRIBER_DAEMON=1)" - elif pid="$(start_watchdog update "$WS")"; then + # shellcheck disable=SC2086 + elif pid="$(start_watchdog update "$WS")" && mam_monitor_started "$WS" $pid; then echo "monitor: restarted (PID $pid)" else echo "ERROR: could not restart the monitor daemon" >&2 diff --git a/.agents/skills/multi-agent-mux-update/scripts/update.sh b/.agents/skills/multi-agent-mux-update/scripts/update.sh index 60da74e..ba903a1 100755 --- a/.agents/skills/multi-agent-mux-update/scripts/update.sh +++ b/.agents/skills/multi-agent-mux-update/scripts/update.sh @@ -104,6 +104,8 @@ _restart_monitor() { fi local pid pid=$(start_watchdog update "$WS") || { echo "ERROR: could not restart the monitor daemon" >&2; return 1; } + # shellcheck disable=SC2086 + mam_monitor_started "$WS" $pid || { echo "ERROR: could not restart the monitor daemon (PID ${pid:-none} is not its monitor)" >&2; return 1; } echo "monitor: restarted (PID $pid)" } diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py index dfcd00f..cbce937 100644 --- a/tests/test_issue72_update.py +++ b/tests/test_issue72_update.py @@ -146,9 +146,12 @@ class Box: _until(lambda: _lock_held(self.mam / "monitor.lock")) def monitor_pids(self): - r = subprocess.run(["pgrep", "-f", "bash %s --subscribe" % (self.ws / MONITOR_REL)], - capture_output=True, text=True) - return r.stdout.split() + return _exact_monitor_pids(self.ws / MONITOR_REL) + + def decoy(self): + """A process that only names this workspace's monitor in an argument.""" + return self.helper("/bin/bash", "-c", "sleep 120 & wait $!", + "bash %s --subscribe" % (self.ws / MONITOR_REL)) def monitor_starts(self): p = self.mam / "stub-monitor.log" @@ -164,6 +167,20 @@ def _until(cond, timeout=15.0): raise AssertionError("condition not met within %ss" % timeout) +def _exact_monitor_pids(script) -> list: + """`bash