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:
GodopuandClaude Opus 5.5 committed 2026-10-28 02:28:55 +09:00
1 parent 454b77215f
commit cf1b693e85
9 files changed
+209 -32

No files matched your search

+59 -1
View File
@@ -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 = """