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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec378e82c9
commit
179473e5d5
3 files changed
+202
-5
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -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 <path> --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 <base>'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
|
||||
Reference in new issue
Block a user