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 `<bash> <reconcile.sh> --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 <noreply@anthropic.com>
This commit is contained in:
1 parent
454b77215f
commit
cf1b693e85
9 files changed
+209
-32
No files matched your search
@@ -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 <path> --agent <agent> --role <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"
|
||||
],
|
||||
|
||||
@@ -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")])
|
||||
|
||||
@@ -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 <this
|
||||
workspace's reconcile.sh> --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 = """
|
||||
|
||||
Reference in new issue
Block a user