Files
multi-agent-mux/tests/test_issue21_loop_b.py
T

539 lines
25 KiB
Python

"""Issue #21 phase 2, loop B, item 5: review identity.
With --worktree/--base every review round is pinned to a snapshot of the
source (loop_id, base, head, tree), reviewed in a detached review worktree (or
env isolation), judged stale when the source changes during the round, and
re-checked before promotion. Remote reviewers cannot reach this host's review
tree: refused by default, admitted diff-only on request, recorded as such.
Without the options nothing changes. All repositories live under tmp_path.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from test_issue21_worktree_prereqs import GIT_ID, RUN_LOOP_SH, SKILLS, _git, wt_repo # noqa: F401
DIFF_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
REMOTE_ROW = {"name": "reviewer-far-01", "agent": "claude", "role": "reviewer", "status": "running",
"transport": "remote"}
LOCAL_ROWS = [{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"},
{"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"}]
WORK_TAIL = "코드 작업·검증 대상은 위 소스 경로다"
def _state(monkeypatch, rows):
monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": rows}))
def _drive(monkeypatch, tmp_path, argv, on_review=None, reviewers="reviewer-claude-01", max_loop=1):
"""run() with delegation stubbed: every job completes, every report is a
PASS; returns (rc, [(session, role, prompt)], runner)."""
from lib_py.loop import runner as lr
monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None)
args = ["--creator", "creator-claude-01", "--task", "t21", "--max-loop", str(max_loop), *argv]
if reviewers:
args += ["--reviewer", reviewers]
r = lr.LoopRunner(args)
calls = []
report = tmp_path / "pass-report.md"
report.write_text("looks fine\n\n[VERDICT: PASS]\n")
def fake_delegate(session, agent_type, role, prompt):
calls.append((session, role, prompt))
if role == "Reviewer" and on_review:
on_review(r, len(calls), prompt)
return f"j{len(calls)}"
monkeypatch.setattr(r, "delegate_job", fake_delegate)
monkeypatch.setattr(r, "wait_for_job", lambda *a, **k: True)
monkeypatch.setattr(r, "find_report", lambda jid: report)
return r.run(), calls, r
def _reviews(calls):
return [(s, p) for s, role, p in calls if role == "Reviewer"]
def _impl(wt: Path) -> str:
"""Commit an implementation in the worktree; return the pre-implementation sha."""
before = _git(wt, "rev-parse", "HEAD")
(wt / "impl.txt").write_text("impl line\n")
_git(wt, "add", "impl.txt")
_git(wt, "commit", "-q", "-m", "impl")
return before
def _worktree_count(main: Path) -> int:
return _git(main, "worktree", "list", "--porcelain").count("\nworktree ") + 1
def _sidecars(main: Path) -> list:
return sorted((main / ".agents" / "reports").rglob("*.identity.json"))
# ---------------------------------------------------------------------------
def test_l1_without_options_nothing_changes(wt_repo, monkeypatch, tmp_path):
main, _ = wt_repo
(main / "a.txt").write_text("a\nchanged in main\n")
n = _worktree_count(main)
rc, calls, r = _drive(monkeypatch, tmp_path, [])
assert rc == 0
assert all("[리뷰 identity]" not in p and "[워크트리 컨텍스트]" not in p for _, _, p in calls)
assert _worktree_count(main) == n
assert r.loop_id.startswith("loop-") and r._rounds == []
assert _sidecars(main) == []
def test_l2_snapshot_pins_uncommitted_and_untracked_not_ignored(wt_repo):
main, wt = wt_repo
(wt / ".gitignore").write_text("*.log\n")
_git(wt, "add", ".gitignore")
_git(wt, "commit", "-q", "-m", "ignore logs")
(wt / "a.txt").write_text("a\nmodified\n")
(wt / "new.txt").write_text("untracked\n")
(wt / "x.log").write_text("ignored\n")
status0 = _git(wt, "status", "--porcelain")
refs0 = _git(wt, "for-each-ref")
from lib_py.loop.runner import LoopRunner
r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)])
r.parse_args()
r.base_commit = _git(wt, "rev-parse", "HEAD")
snap = r._snapshot(1)
assert snap and snap["dirty"] and snap["review_commit"] != snap["head"]
assert r._worktree_tree() == snap["tree"], "the snapshot tree is stable for an unchanged tree"
path = Path(r._open_review_tree(snap, 1))
try:
assert (path / "new.txt").read_text() == "untracked\n"
assert "modified" in (path / "a.txt").read_text()
assert not (path / "x.log").exists(), "ignored files stay out of the snapshot"
assert _git(path, "rev-parse", "HEAD") == snap["review_commit"]
finally:
r._close_review_tree(str(path))
assert not path.exists()
assert _git(wt, "status", "--porcelain") == status0 and _git(wt, "for-each-ref") == refs0
def test_l3_l5_stale_round_is_re_reviewed_and_the_sidecar_matches(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
before = _impl(wt)
seen = {}
def creator_commits_during_first_review(r, n, prompt):
if "h1" not in seen:
seen["h1"] = _git(wt, "rev-parse", "HEAD")
(wt / "late.txt").write_text("late\n")
_git(wt, "add", "late.txt")
_git(wt, "commit", "-q", "-m", "late")
seen["h2"] = _git(wt, "rev-parse", "HEAD")
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"],
on_review=creator_commits_during_first_review, max_loop=2)
assert rc == 0
assert [role for _, role, _ in calls] == ["Reviewer", "Reviewer"], "no correction job for a stale round"
assert r._rounds[0]["stale"] is True and r._rounds[1]["stale"] is False
assert r._rounds[1]["head_sha"] == seen["h2"]
assert f"- head: {seen['h2']}" in _reviews(calls)[1][1]
(side,) = _sidecars(main)
data = json.loads(side.read_text())
assert data["loop_id"] == r.loop_id and data["head_sha"] == seen["h2"]
assert data["tree_sha"] == r._rounds[1]["tree_sha"] and data["base_sha"] == before
assert (data["transport"], data["evidence"], data["isolation"]) == ("herdr", "review-tree", "worktree")
def test_l3b_stale_last_round_fails(wt_repo, monkeypatch, tmp_path, capsys):
main, wt = wt_repo
before = _impl(wt)
def commit(r, n, prompt):
(wt / f"late{n}.txt").write_text("late\n")
_git(wt, "add", ".")
_git(wt, "commit", "-q", "-m", "late")
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"],
on_review=commit, max_loop=1)
assert rc == 1 and "changed during review" in capsys.readouterr().err
assert _sidecars(main) == []
def test_l4_source_changed_after_approval_is_not_promoted(wt_repo, monkeypatch, tmp_path, capsys):
main, wt = wt_repo
before = _impl(wt)
from lib_py.loop import runner as lr
real = lr.LoopRunner._identity_now
state = {"accepted": False}
def identity_now(self):
return ("changed", "changed") if state["accepted"] else real(self)
def mark(r, n, prompt):
state["accepted"] = False
monkeypatch.setattr(lr.LoopRunner, "_identity_now", identity_now)
monkeypatch.setattr(lr.LoopRunner, "_round_is_stale",
lambda self, snap: state.__setitem__("accepted", True) or False)
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"],
on_review=mark)
assert rc == 1 and "source changed after approval" in capsys.readouterr().err
assert not list((main / ".agents" / "reports").rglob("report-*.md"))
def test_l6_env_isolation_reviews_the_source_tree(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
before = _impl(wt)
n = _worktree_count(main)
counts = []
rc, calls, r = _drive(monkeypatch, tmp_path,
["--worktree", str(wt), "--base", before, "--review-only", "--review-isolation", "env"],
on_review=lambda r, i, p: counts.append(_worktree_count(main)))
assert rc == 0 and counts == [n], "env isolation opens no review worktree"
prompt = _reviews(calls)[0][1]
assert f"- 검증 경로: {os.path.realpath(wt)}" in prompt and "PYTHONDONTWRITEBYTECODE=1" in prompt
assert WORK_TAIL not in prompt and "검증 경로(= 위 소스 경로)" in prompt, "one verification target (B-2)"
data = json.loads(_sidecars(main)[0].read_text())
assert (data["evidence"], data["isolation"]) == ("source-tree", "env")
def test_l7_review_tree_failure_refuses_without_lowering_isolation(wt_repo, monkeypatch, tmp_path, capsys):
main, wt = wt_repo
before = _impl(wt)
from lib_py.loop import runner as lr
real_git = lr.LoopRunner._git
def git(cwd, *args):
if args[:2] == ("worktree", "add"):
return subprocess.CompletedProcess(args, 128, "", "fatal: simulated")
return real_git(cwd, *args)
monkeypatch.setattr(lr.LoopRunner, "_git", staticmethod(git))
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"])
assert rc == 1 and _reviews(calls) == []
assert "cannot open the review worktree" in capsys.readouterr().err
def test_l8_release_guard_closes_open_review_trees(wt_repo):
main, wt = wt_repo
(wt / "a.txt").write_text("a\nx\n")
from lib_py.loop.runner import LoopRunner
r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)])
r.parse_args()
r.base_commit = _git(wt, "rev-parse", "HEAD")
path = r._open_review_tree(r._snapshot(1), 1)
assert os.path.isdir(path)
r.release_guard()
assert not os.path.exists(path) and path not in _git(main, "worktree", "list")
@pytest.mark.parametrize("opt", [["--review-isolation", "env"], ["--remote-review", "diff-only"]])
def test_l9_l16_legacy_shell_loop_refuses_the_new_options(tmp_path, opt):
marker = tmp_path / "loop-marker"
env = {**os.environ, "WORKSPACE_ROOT": str(tmp_path), "MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1",
"MAM_LOOP_MARKER": str(marker)}
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", "c", "--task", "t", *opt],
capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=60)
assert res.returncode == 1 and "require the Python loop (MAM_LOOP_PY=1)" in res.stderr
assert not marker.exists()
def test_l9b_invalid_values_are_rejected(wt_repo):
from lib_py.loop.runner import LoopRunner
for opt in (["--review-isolation", "bogus"], ["--remote-review", "maybe"]):
with pytest.raises(SystemExit) as e:
LoopRunner(["--creator", "c", "--task", "t", *opt]).parse_args()
assert e.value.code == 1
@pytest.mark.parametrize("variant", ["reviewer", "all-reviewer", "env", "self-review"])
def test_l10_remote_reviewer_refused_before_any_delegation(wt_repo, monkeypatch, tmp_path, capsys, variant):
main, wt = wt_repo
before = _impl(wt)
rows = LOCAL_ROWS + [REMOTE_ROW]
argv = ["--worktree", str(wt), "--base", before, "--review-only"]
reviewers = "reviewer-far-01"
if variant == "all-reviewer":
argv.append("--all-reviewer")
reviewers = None
elif variant == "env":
argv += ["--review-isolation", "env"]
elif variant == "self-review":
rows = [dict(LOCAL_ROWS[0], transport="remote")]
reviewers = None
_state(monkeypatch, rows)
rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers=reviewers)
err = capsys.readouterr().err
assert rc == 1 and calls == [], "nothing is delegated"
assert "--remote-review diff-only" in err
assert ("creator-claude-01" if variant == "self-review" else "reviewer-far-01") in err
def test_l11_diff_only_remote_reviewer_gets_shas_and_the_snapshot_diff(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
before = _impl(wt)
(wt / "a.txt").write_text("a\nuncommitted change\n")
_state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW])
rc, calls, r = _drive(monkeypatch, tmp_path,
["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"],
reviewers="reviewer-claude-01,reviewer-far-01")
assert rc == 0
prompts = dict(_reviews(calls))
remote, local = prompts["reviewer-far-01"], prompts["reviewer-claude-01"]
snap = r._rounds[0]
assert os.path.realpath(wt) not in remote and "검증 경로" not in remote and "mam-review" not in remote
assert f"- head: {snap['head_sha']}" in remote and f"- tree: {snap['tree_sha']}" in remote
assert "diff 만으로" in remote and "+uncommitted change" in remote and "+impl line" in remote
assert "- 검증 경로: " in local and "mam-review" in local
assert WORK_TAIL not in local and "[리뷰 identity] 의 검증 경로에서만" in local, "one verification target (B-2)"
assert snap["reviewers"]["reviewer-far-01"]["evidence"] == "diff-only"
assert snap["reviewers"]["reviewer-claude-01"]["evidence"] == "review-tree"
side = {json.loads(p.read_text())["reviewer"]: json.loads(p.read_text()) for p in _sidecars(main)}
assert (side["reviewer-far-01"]["evidence"], side["reviewer-far-01"]["isolation"],
side["reviewer-far-01"]["transport"]) == ("diff-only", "none (remote)", "remote")
assert side["reviewer-claude-01"]["evidence"] == "review-tree"
def test_l12_remote_reviewer_without_options_works_as_before(wt_repo, monkeypatch, tmp_path, capsys):
main, _ = wt_repo
(main / "a.txt").write_text("a\nchanged\n")
_state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW])
rc, calls, r = _drive(monkeypatch, tmp_path, ["--remote-review", "diff-only"], reviewers="reviewer-far-01")
assert rc == 0 and [s for s, _ in _reviews(calls)] == ["reviewer-far-01"]
assert "[리뷰 identity]" not in _reviews(calls)[0][1] and _sidecars(main) == []
assert "only apply with --worktree or --base" in capsys.readouterr().out
def _diff_collect(repo, *args, env=None):
cmd = "source " + str(DIFF_SH) + " && mam_collect_changes_diff " + " ".join(f"'{a}'" for a in (repo, *args))
return subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env={**os.environ, **(env or {})})
def test_l13_snapshot_diff_is_commit_to_commit(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
base = _git(wt, "rev-parse", "HEAD")
(wt / ".gitignore").write_text("*.log\n")
(wt / "a.txt").write_text("a\nmodified\n")
(wt / "new.txt").write_text("untracked\n")
(wt / "x.log").write_text("ignored\n")
from lib_py.loop.runner import LoopRunner
r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)])
r.parse_args()
r.base_commit = base
snap = r._snapshot(1)
out = _diff_collect(wt, base, snap["review_commit"])
assert out.returncode == 0 and "+modified" in out.stdout and "+untracked" in out.stdout
assert "x.log" not in out.stdout
bad = _diff_collect(wt, base, "0" * 40)
assert bad.returncode == 2 and "CHANGE SET UNAVAILABLE" in bad.stdout
cut = _diff_collect(wt, base, snap["review_commit"], env={"MAM_DIFF_MAX_LINES": "1"})
assert "DIFF TRUNCATED" in cut.stdout and f"inspect the review snapshot ({snap['review_commit']})" in cut.stdout
# the two-argument form is unchanged: same output as the script before this change
old = subprocess.run(["git", "-C", str(SKILLS.parent.parent), "show", "25f4be3:.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh"],
capture_output=True, text=True)
new2 = _diff_collect(wt, base)
assert new2.returncode == 0 and "+modified" in new2.stdout and "+untracked" in new2.stdout
if old.returncode == 0 and old.stdout: # needs git history (absent in a `git archive` export)
old_sh = tmp_path / "diff_collect_old.sh"
old_sh.write_text(old.stdout)
old2 = subprocess.run(["bash", "-c", f"source '{old_sh}' && mam_collect_changes_diff '{wt}' '{base}'"],
capture_output=True, text=True)
assert (new2.returncode, new2.stdout) == (old2.returncode, old2.stdout)
def test_l13b_prompt_diff_comes_from_the_snapshot(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
before = _impl(wt)
from lib_py.loop import runner as lr
real = lr.LoopRunner._snapshot
def snapshot_then_edit(self, n):
s = real(self, n)
(wt / "after-snapshot.txt").write_text("written after the snapshot\n")
return s
monkeypatch.setattr(lr.LoopRunner, "_snapshot", snapshot_then_edit)
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"])
prompt = _reviews(calls)[0][1]
assert "+impl line" in prompt and "after-snapshot.txt" not in prompt
assert rc == 1, "the source changed after the snapshot: the round is stale"
def test_l14_untracked_nested_repository_is_refused(wt_repo, monkeypatch, tmp_path, capsys):
main, wt = wt_repo
before = _impl(wt)
nested = wt / "nested"
nested.mkdir()
_git(nested, "init", "-q")
(nested / "n.txt").write_text("n\n")
_git(nested, "add", "n.txt")
_git(nested, "commit", "-q", "-m", "n")
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"])
assert rc == 1 and _reviews(calls) == []
assert "untracked nested git repository nested" in capsys.readouterr().err
def test_l14b_nested_repository_without_options_proceeds(wt_repo, monkeypatch, tmp_path):
main, _ = wt_repo
nested = main / "nested"
nested.mkdir()
_git(nested, "init", "-q")
rc, calls, r = _drive(monkeypatch, tmp_path, [])
assert rc == 0 and len(_reviews(calls)) == 1
def test_l15_truncated_diff_refuses_diff_only_but_not_local(wt_repo, monkeypatch, tmp_path, capsys):
main, wt = wt_repo
before = _impl(wt)
# the runner uses the control root's collector (the python fallback never truncates)
dst = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts"
dst.mkdir(parents=True)
(dst / "diff_collect.sh").write_text(DIFF_SH.read_text())
(dst.parent.parent / "lib_int.sh").write_text((SKILLS / "lib_int.sh").read_text()) # its integer rule
(dst.parent.parent / "lib_py").mkdir() # Issue #17 item 4: lib_int.sh runs lib_py/ints.py
(dst.parent.parent / "lib_py" / "ints.py").write_text((SKILLS / "lib_py" / "ints.py").read_text())
monkeypatch.setenv("MAM_DIFF_MAX_LINES", "1")
_state(monkeypatch, LOCAL_ROWS + [REMOTE_ROW])
argv = ["--worktree", str(wt), "--base", before, "--review-only", "--remote-review", "diff-only"]
rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers="reviewer-claude-01,reviewer-far-01")
assert rc == 1 and _reviews(calls) == [] and "would have no evidence" in capsys.readouterr().err
rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers="reviewer-claude-01")
assert rc == 0 and len(_reviews(calls)) == 1
# ---------------------------------------------------------------------------
# loop B review follow-up: B-2, B-R1..B-R3, N1, N3, N5
# ---------------------------------------------------------------------------
def _add_submodule(wt: Path, tmp_path: Path) -> None:
src = tmp_path / "sm-src"
src.mkdir()
_git(src, "init", "-q")
(src / "x.txt").write_text("original\n")
_git(src, "add", "x.txt")
_git(src, "commit", "-q", "-m", "initial")
_git(wt, "-c", "protocol.file.allow=always", "submodule", "add", "-q", str(src), "sm")
_git(wt, "commit", "-q", "-m", "add sm")
@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "untracked-hidden", "during", "during-hidden"])
def test_l17_dirty_submodule_is_refused_or_makes_the_round_stale(wt_repo, monkeypatch, tmp_path, capsys, when):
"""The -hidden variants (PR #29 review R2): status.showUntrackedFiles=no in
the submodule must not let an untracked file past the snapshot guard,
neither before the first review nor when it appears mid-round."""
main, wt = wt_repo
base = _impl(wt)
_add_submodule(wt, tmp_path)
if when == "before-ignored": # no ignore setting hides the dirty content
_git(wt, "config", "submodule.sm.ignore", "all")
_git(wt, "config", "diff.ignoreSubmodules", "all")
if when.endswith("-hidden"):
_git(wt / "sm", "config", "status.showUntrackedFiles", "no")
def dirty(*_):
(wt / "sm" / "x.txt").write_text("unreviewed work\n")
def untracked(*_):
(wt / "sm" / "new.txt").write_text("untracked in the submodule\n")
if when.startswith("before"):
dirty()
elif when.startswith("untracked"):
untracked()
on_review = {"during": dirty, "during-hidden": untracked}.get(when)
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"],
on_review=on_review)
assert rc == 1 and _sidecars(main) == []
err = capsys.readouterr().err
assert "submodule(s) with uncommitted or untracked content: sm" in err
if when.startswith("during"):
assert len(_reviews(calls)) == 1 and r._rounds[0]["stale"] is True
else:
assert _reviews(calls) == [], "refused before any review"
def test_l17b_clean_submodule_is_reviewed(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
base = _impl(wt)
_add_submodule(wt, tmp_path)
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"])
assert rc == 0 and len(_sidecars(main)) == 1
def test_l18_self_review_is_a_recorded_verdict_and_not_pass_re_reviews(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
base = _impl(wt)
report = tmp_path / "pass-report.md"
def verdicts(r, n, prompt):
report.write_text("[VERDICT: NOT PASS]\n" if n == 1 else "[VERDICT: PASS]\n")
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"],
on_review=verdicts, reviewers=None, max_loop=2)
assert rc == 0
assert [(s, role) for s, role, _ in calls] == [("creator-claude-01", "Reviewer")] * 2, \
"a NOT PASS self-review is re-reviewed, no correction job"
prompt = _reviews(calls)[1][1]
assert "Self-Review" in prompt and "+impl line" in prompt and "[리뷰 identity]" in prompt
assert r._rounds[0]["verdicts"] == {"creator-claude-01": "NOT PASS"}
(side,) = _sidecars(main)
data = json.loads(side.read_text())
assert (data["reviewer"], data["loop_id"], data["head_sha"]) == ("creator-claude-01", r.loop_id,
_git(wt, "rev-parse", "HEAD"))
assert data["evidence"] == "review-tree"
def test_l18b_self_review_without_options_is_unchanged(wt_repo, monkeypatch, tmp_path):
main, _ = wt_repo
(main / "a.txt").write_text("a\nchanged\n")
rc, calls, r = _drive(monkeypatch, tmp_path, [], reviewers=None)
(review,) = _reviews(calls)
assert rc == 0 and review[0] == "creator-claude-01"
assert review[1].startswith("작업 완료 상태에 대해 스스로 검증") and "변경분" not in review[1]
assert _sidecars(main) == []
def test_l19_remote_self_review_gets_the_snapshot_diff(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
base = _impl(wt)
_state(monkeypatch, [dict(LOCAL_ROWS[0], transport="remote")])
rc, calls, r = _drive(monkeypatch, tmp_path,
["--worktree", str(wt), "--base", base, "--review-only", "--remote-review", "diff-only"],
reviewers=None)
assert rc == 0
prompt = _reviews(calls)[0][1]
assert "diff --git" in prompt and "+impl line" in prompt and "검증 경로" not in prompt
data = json.loads(_sidecars(main)[0].read_text())
assert (data["reviewer"], data["evidence"]) == ("creator-claude-01", "diff-only")
def test_l20_snapshot_diff_failure_keeps_the_unavailable_marker(wt_repo):
main, wt = wt_repo
dst = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts"
dst.mkdir(parents=True)
(dst / "diff_collect.sh").write_text("mam_collect_changes_diff() { return 1; }\n")
from lib_py.loop.runner import UNAVAILABLE_MARKER, LoopRunner
r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)])
r.parse_args()
r.base_commit = _git(wt, "rev-parse", "HEAD")
assert UNAVAILABLE_MARKER in r.collect_changes_diff(r.base_commit)
assert UNAVAILABLE_MARKER not in r.collect_changes_diff(), "the live-tree form keeps its git fallback"
def test_l21_closing_a_review_tree_keeps_other_worktree_entries_and_its_parent_dir(wt_repo, monkeypatch, tmp_path):
main, wt = wt_repo
base = _impl(wt)
other = tmp_path / "detached-disk"
_git(main, "worktree", "add", "-q", "--detach", str(other))
import shutil
import tempfile
shutil.rmtree(other) # a worktree whose directory is gone (a detached disk)
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"])
assert rc == 0
assert str(other) in _git(main, "worktree", "list", "--porcelain"), "the loop does not prune others' entries"
assert not (Path(tempfile.gettempdir()) / "mam-review" / r.loop_id).exists()