fix(issue-21): loop B review follow-up - one verification target per review prompt, dirty submodules refused, identity self-review recorded like any verdict

- review jobs' [워크트리 컨텍스트] points at the [리뷰 identity] path only (B-2);
  remote re-adjudication gets a matching sentence (N-4)
- dirty/untracked submodule content fails the snapshot and makes a round stale,
  regardless of submodule ignore settings (B-R1)
- with --worktree/--base and no reviewer, the creator self-review takes the
  reviewer path: snapshot diff, evidence check, pre-promotion re-check and the
  identity sidecar; NOT PASS still re-reviews without a correction job (B-R2/B-R3)
- snapshot diff failures keep the UNAVAILABLE marker (N-1); prune only when
  worktree remove fails (N-3); the empty per-loop review dir is removed (N-5)
- tests L17-L21, B-2 assertions in L6/L11; SKILL.md env stale note; import order

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-14 22:34:12 +00:00
1 parent e6d2168a13
commit a21986ec3a
4 files changed
+217 -33

No files matched your search

+127
View File
@@ -25,6 +25,7 @@ REMOTE_ROW = {"name": "reviewer-far-01", "agent": "claude", "role": "reviewer",
"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):
@@ -195,6 +196,7 @@ def test_l6_env_isolation_reviews_the_source_tree(wt_repo, monkeypatch, tmp_path
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")
@@ -287,6 +289,7 @@ def test_l11_diff_only_remote_reviewer_gets_shas_and_the_snapshot_diff(wt_repo,
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)}
@@ -397,3 +400,127 @@ def test_l15_truncated_diff_refuses_diff_only_but_not_local(wt_repo, monkeypatch
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", "during"])
def test_l17_dirty_submodule_is_refused_or_makes_the_round_stale(wt_repo, monkeypatch, tmp_path, capsys, when):
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")
def dirty(*_):
(wt / "sm" / "x.txt").write_text("unreviewed work\n")
if when.startswith("before"):
dirty()
elif when == "untracked":
(wt / "sm" / "new.txt").write_text("untracked in the submodule\n")
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"],
on_review=dirty if when == "during" else None)
assert rc == 1 and _sidecars(main) == []
err = capsys.readouterr().err
assert "submodule(s) with uncommitted or untracked content: sm" in err
if when == "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()