fix(reset): refuse every registration during a reset and purge only ids owned by the row's folder (#78)

Review round 2 (codex):
- R1: register_job reads .mam/reset-in-progress under the registry lock and
  refuses while a reset of the workspace owning <ws>/.mam/jobs is live, so the
  public registry.py register CLI and the API no longer bypass the gate. The
  reset's precheck takes that lock once after publishing its marker, so a
  registration is either seen by it or refused. The reset's own create passes
  MAM_RESET_OWNER_PID; the remote runner refuses the dispatch.
- R2: before each purge the reset re-runs stop's revalidate check
  (verify_session_uuid) for a post-discovered id against the row's folder and
  skips the target (id not verified) when it does not verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-30 03:45:27 +09:00
1 parent 18ea5f6184
commit 548c6854ea
7 files changed
+312 -33

No files matched your search

+174
View File
@@ -1788,3 +1788,177 @@ def test_mam_reset_admit_refuses_only_a_live_reset(mam_sandbox: Path):
holder.wait()
res_stale = admit()
assert res_stale.returncode == 0, res_stale.stderr
def test_registry_cli_is_refused_after_the_target_recheck(mam_sandbox: Path, mock_herdr: Path, mock_agents):
"""Review round 2 R1 (codex): the documented registry CLI, run by a caller
that is not a child of the reset, after the target's open-job re-check and
before its stop, is refused by .mam/reset-in-progress and registers nothing;
the reset's own create still registers its onboarding job."""
import threading
name = "reviewer-claude-01"
_claude_transcript(mam_sandbox, mam_sandbox, UA)
_mock_herdr_agent(mock_herdr, name, mam_sandbox, agent="claude")
_write_rows(mam_sandbox, [_row(name, "claude", mam_sandbox, own_id=UA)])
stop = mam_sandbox / ".agents/skills/multi-agent-mux-stop/scripts/stop_session.sh"
original = stop.with_name("stop-original.sh")
stop.rename(original)
entered, release = mam_sandbox / "stop-entered", mam_sandbox / "stop-release"
gate = mam_sandbox / "stop-gate.py"
gate.write_text(
f"""import os, pathlib, sys, time
entered = pathlib.Path({str(entered)!r})
release = pathlib.Path({str(release)!r})
if not entered.exists():
entered.touch()
end = time.monotonic() + 60
while not release.exists() and time.monotonic() < end:
time.sleep(0.02)
os.execv('/bin/bash', ['bash', {str(original)!r}, *sys.argv[1:]])
""",
encoding="utf-8",
)
stop.write_text("#!/bin/bash\nexec %s %s \"$@\"\n" % (shlex.quote(sys.executable), shlex.quote(str(gate))))
stop.chmod(0o755)
jobs_dir = mam_sandbox / ".mam" / "jobs"
record: dict = {}
def writer():
try:
end = time.monotonic() + 60
while not entered.exists() and time.monotonic() < end:
time.sleep(0.02)
record["marker"] = (mam_sandbox / ".mam" / "reset-in-progress").is_file()
before = sorted(p.name for p in jobs_dir.glob("*.json"))
registry = mam_sandbox / ".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
res = subprocess.run(
[sys.executable, str(registry), "--registry-dir", str(jobs_dir), "register",
"--prompt", "external concurrent work", "--agent", "claude-code", "--agent-session", "herdr:" + name],
capture_output=True, text=True, timeout=30,
)
record.update(rc=res.returncode, stdout=res.stdout, stderr=res.stderr, before=before,
after=sorted(p.name for p in jobs_dir.glob("*.json")))
finally:
release.touch()
worker = threading.Thread(target=writer)
worker.start()
try:
res = _run_reset(mam_sandbox, "--yes")
finally:
worker.join(timeout=90)
_wait_reconcile_idle(mam_sandbox)
assert not worker.is_alive()
assert record["marker"] is True, record
assert record["rc"] == 1, record
assert "a reset of %s is in progress" % mam_sandbox in record["stderr"], record
assert record["stdout"] == "" and record["after"] == record["before"], record
assert res.returncode == 0, res.stdout + res.stderr
assert _result_of(res.stdout, name) == "done"
onboarding = [json.loads(p.read_text(encoding="utf-8")) for p in jobs_dir.glob("*.json")]
assert [j["agent_session"] for j in onboarding] == ["herdr:" + name], onboarding
def test_register_job_refuses_only_a_live_reset_not_its_own(mam_sandbox: Path, monkeypatch):
from lib_py.delegation import registry
jobs_dir = str(mam_sandbox / ".mam" / "jobs")
marker = mam_sandbox / ".mam" / "reset-in-progress"
monkeypatch.delenv(registry.RESET_OWNER_ENV, raising=False)
holder = subprocess.Popen(["sleep", "120"])
try:
_live_marker(marker, holder)
with pytest.raises(registry.ResetInProgressError, match="a reset of %s is in progress" % mam_sandbox):
registry.register_job(prompt="p", agent_session="herdr:x", registry_dir=jobs_dir)
assert not list(Path(jobs_dir).glob("*.json"))
# Another control plane's registry is not this workspace's .mam/jobs
assert registry.reset_refusal(str(mam_sandbox / "elsewhere")) is None
monkeypatch.setenv(registry.RESET_OWNER_ENV, str(holder.pid))
assert registry.register_job(prompt="p", agent_session="herdr:x", registry_dir=jobs_dir)
monkeypatch.delenv(registry.RESET_OWNER_ENV)
finally:
holder.kill()
holder.wait()
assert registry.register_job(prompt="p", agent_session="herdr:x", registry_dir=jobs_dir)
assert len(list(Path(jobs_dir).glob("*.json"))) == 2
def test_precheck_waits_for_a_registration_holding_the_registry_lock(tmp_path: Path):
"""R1 lock order: a registration that read no reset marker holds the registry
lock until its record is written; the precheck, run after the marker is
published, takes that lock before it lists the open jobs, so it sees the job."""
import fcntl
ws = tmp_path / "ws"
jobs_dir = ws / ".mam" / "jobs"
jobs_dir.mkdir(parents=True)
state = {"herdr_sessions": [_row("reviewer-claude-01", "claude", ws, own_id=UA)]}
env = dict(os.environ, PYTHONPATH=str(SKILLS), PYTHONDONTWRITEBYTECODE="1", MAM_STATE_JSON=json.dumps(state))
env.pop("MAM_JOBS_DIR", None)
lock = open(jobs_dir / ".lock", "a")
fcntl.flock(lock, fcntl.LOCK_EX)
try:
proc = subprocess.Popen(
[sys.executable, "-m", "lib_py.reset", "precheck", "--workspace", str(ws)],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
time.sleep(1.5)
assert proc.poll() is None, proc.communicate()
(jobs_dir / "job78locked.json").write_text(
json.dumps({"job_id": "job78locked", "agent_session": "herdr:reviewer-claude-01", "status": "pending"}),
encoding="utf-8",
)
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
lock.close()
out, err = proc.communicate(timeout=30)
assert proc.returncode == 1, out + err
assert "job job78locked" in out
@pytest.mark.parametrize("artifact_cwd", ["own", "foreign"])
def test_post_discovered_id_is_purged_only_when_it_verifies_for_its_folder(
mam_sandbox: Path, mock_herdr: Path, mock_agents, monkeypatch, artifact_cwd: str
):
"""Review round 2 R2 (codex): a recorded codex id whose rollout names another
folder is not purged (stop's own unverified-own purge is for a hand-run
cleanup); the same rollout naming the row's folder is."""
from lib_py.agents.base import DiscoveryContext
from lib_py.agents.registry import get_adapter
foreign = mam_sandbox / "another-workspace"
foreign.mkdir()
cwd = mam_sandbox if artifact_cwd == "own" else foreign
home = mam_sandbox / ".codex"
monkeypatch.setenv("CODEX_HOME", str(home))
rollout = home / "sessions/2026/10/30" / ("rollout-2026-10-30T00-00-00-%s.jsonl" % UB)
rollout.parent.mkdir(parents=True)
rollout.write_text(json.dumps({"type": "session_meta", "payload": {"id": UB, "cwd": str(cwd)}}) + "\n")
name = "reviewer-codex-01"
row = _row(name, "codex", mam_sandbox, own_id=UB)
row["session_id_verified"] = False
_mock_herdr_agent(mock_herdr, name, mam_sandbox, agent="codex")
_write_rows(mam_sandbox, [row])
# The CLI delete fails, so the adapter's own fallback removes the rollout
codex = mam_sandbox / "bin" / "codex"
codex.write_text('#!/bin/bash\nif [ "$1" = delete ]; then exit 1; fi\nexit 0\n')
codex.chmod(0o755)
ctx = DiscoveryContext(workspace=str(mam_sandbox), agent_name="codex", home_dir=str(mam_sandbox),
row=row, mode="revalidate")
assert get_adapter("codex").verify_artifact(UB, ctx) is (artifact_cwd == "own")
res = _run_reset(mam_sandbox, "--yes")
_wait_reconcile_idle(mam_sandbox)
if artifact_cwd == "own":
assert res.returncode == 0, res.stdout + res.stderr
assert _result_of(res.stdout, name) == "done"
assert not rollout.exists()
else:
assert res.returncode == 4, res.stdout + res.stderr
assert _result_of(res.stdout, name) == "skipped: id not verified"
assert "--purge-conversation --yes" not in res.stdout
assert rollout.is_file()
kept = _read_rows(mam_sandbox)[name]
assert kept["status"] == "stopped" and kept["codex_session_id_own"] == UB
assert (mam_sandbox / ".mam" / "reset-spec.json").is_file()