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:
1 parent
e6d2168a13
commit
a21986ec3a
4 files changed
+217
-33
No files matched your search
@@ -24,8 +24,8 @@ sandbox); each stays under a minute.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user