feat(issue-21): item 5 review identity - per-round snapshot and snapshot diff, detached review worktree (--review-isolation), stale-round re-review, identity records with evidence, pre-promotion re-check; remote reviewers refused or diff-only (--remote-review)
Active with --worktree/--base only; without them prompts, flow and promotion are unchanged (a loop_id is recorded in the checkpoint either way). - Every review round is pinned (B3): the source's HEAD and its working state as a tree built through a temporary index (the real index/status/refs are untouched); a dirty state becomes a ref-less snapshot commit on HEAD (committed as mam-loop, not as the user). An untracked nested repository cannot be pinned and is refused. identity = (loop_id, base, head, tree). - Review isolation (B4): a detached review worktree of the snapshot under TMPDIR (default), or `--review-isolation env` (the source root with env and temp-dir instructions). Failing to open the tree refuses the round - the isolation is never lowered silently. Trees are removed after the round and on any exit (run() wraps the body; release_guard too). - The reviewers' diff comes from the snapshot (B13): diff_collect.sh takes an optional head commit for a commit-to-commit diff; its two-argument form is byte-identical. - Stale rounds (B7/B8): after the round the source is measured again; if it changed, the round's approvals are discarded and the next iteration re-reviews without a correction job (last iteration: rc 1). Only the last non-stale round's PASSes are promoted. - Records (B9/B15): review_rounds in the checkpoint with per-reviewer transport/evidence, and report-<jid>.identity.json next to each promoted report: evidence review-tree | source-tree | diff-only. - Pre-promotion re-check (B10): a source changed after approval is not promoted (rc 1). - Remote reviewers (B14, Creator challenge 91c81095): they cannot reach this host's review tree. Checked before anything is delegated: refused by default; `--remote-review diff-only` admits them with the snapshot SHAs and diff as their only evidence (no local path in their prompt), and refuses a round whose diff is truncated or unavailable. - run_loop.sh accepts both options (usage identical to the runner's); the legacy shell loop refuses them before the lock. Tests tests/test_issue21_loop_b.py L1-L16 (22). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
1 parent
4ad622e372
commit
e57663ac4c
4 files changed
+748
-17
No files matched your search
@@ -13,12 +13,14 @@ import glob
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
@@ -73,6 +75,14 @@ class LoopRunner:
|
|||||||
self.base_sha = ""
|
self.base_sha = ""
|
||||||
self.base_source = "head"
|
self.base_source = "head"
|
||||||
self.review_only = False
|
self.review_only = False
|
||||||
|
# Issue #21 item 5: review identity (active with --worktree/--base)
|
||||||
|
self.loop_id = "loop-" + time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "-" + secrets.token_hex(3)
|
||||||
|
self.review_isolation = "worktree"
|
||||||
|
self.remote_review = "refuse"
|
||||||
|
self._remote_recipients: Set[str] = set()
|
||||||
|
self._review_paths: List[str] = []
|
||||||
|
self._rounds: List[Dict[str, Any]] = []
|
||||||
|
self._accepted_round: Optional[Dict[str, Any]] = None
|
||||||
self.created_jobs: List[str] = []
|
self.created_jobs: List[str] = []
|
||||||
self.failed_submit_jobs: Set[str] = set()
|
self.failed_submit_jobs: Set[str] = set()
|
||||||
self.current_plan = ""
|
self.current_plan = ""
|
||||||
@@ -111,6 +121,8 @@ class LoopRunner:
|
|||||||
" --worktree <path> Source worktree for diff/base/head (control plane stays here)",
|
" --worktree <path> Source worktree for diff/base/head (control plane stays here)",
|
||||||
" --base <ref> Pin the review diff base (must be an ancestor of the source HEAD)",
|
" --base <ref> Pin the review diff base (must be an ancestor of the source HEAD)",
|
||||||
" --review-only Skip implementation; review <base>..HEAD (requires --base)",
|
" --review-only Skip implementation; review <base>..HEAD (requires --base)",
|
||||||
|
" --review-isolation <v> Review tree with --worktree/--base: worktree (default) or env",
|
||||||
|
" --remote-review <v> Remote reviewers with --worktree/--base: refuse (default) or diff-only",
|
||||||
]
|
]
|
||||||
print("\n".join(lines))
|
print("\n".join(lines))
|
||||||
|
|
||||||
@@ -195,6 +207,19 @@ class LoopRunner:
|
|||||||
elif arg == "--review-only":
|
elif arg == "--review-only":
|
||||||
self.review_only = True
|
self.review_only = True
|
||||||
i += 1
|
i += 1
|
||||||
|
elif arg in ("--review-isolation", "--remote-review"):
|
||||||
|
if i + 1 >= len(args):
|
||||||
|
self.print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
allowed = ("worktree", "env") if arg == "--review-isolation" else ("refuse", "diff-only")
|
||||||
|
if args[i + 1] not in allowed:
|
||||||
|
print(f"ERROR: {arg} must be one of: {', '.join(allowed)}.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if arg == "--review-isolation":
|
||||||
|
self.review_isolation = args[i + 1]
|
||||||
|
else:
|
||||||
|
self.remote_review = args[i + 1]
|
||||||
|
i += 2
|
||||||
else:
|
else:
|
||||||
print(f"Unknown option: {arg}")
|
print(f"Unknown option: {arg}")
|
||||||
self.print_usage()
|
self.print_usage()
|
||||||
@@ -234,6 +259,8 @@ class LoopRunner:
|
|||||||
if self.base_ref is not None:
|
if self.base_ref is not None:
|
||||||
self.base_sha = self._resolve_base(self.base_ref)
|
self.base_sha = self._resolve_base(self.base_ref)
|
||||||
self.base_source = "cli"
|
self.base_source = "cli"
|
||||||
|
if not self._context_active() and any(a in self.raw_argv for a in ("--review-isolation", "--remote-review")):
|
||||||
|
log_warn("--review-isolation/--remote-review only apply with --worktree or --base; ignored.")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||||
@@ -490,21 +517,26 @@ class LoopRunner:
|
|||||||
return matches[0]
|
return matches[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def collect_changes_diff(self) -> str:
|
def collect_changes_diff(self, head_commit: Optional[str] = None) -> str:
|
||||||
"""Collect git diff since base commit."""
|
"""Collect git diff since base commit - of the live source tree, or,
|
||||||
|
with head_commit (review identity, B13), of base..head_commit."""
|
||||||
# The collector is the control root's copy (never the worktree's
|
# The collector is the control root's copy (never the worktree's
|
||||||
# unreviewed one); the tree it diffs is the source root (Issue #21).
|
# unreviewed one); the tree it diffs is the source root (Issue #21).
|
||||||
diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
|
diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
|
||||||
src = str(self.worktree_root)
|
src = str(self.worktree_root)
|
||||||
if diff_script.exists():
|
if diff_script.exists():
|
||||||
cmd = (f"source {shlex.quote(str(diff_script))} && "
|
cmd = (f"source {shlex.quote(str(diff_script))} && "
|
||||||
f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}")
|
f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}"
|
||||||
|
+ (f" {shlex.quote(head_commit)}" if head_commit else ""))
|
||||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src)
|
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src)
|
||||||
if res.returncode == 0:
|
if res.returncode == 0:
|
||||||
return res.stdout
|
return res.stdout
|
||||||
# Fallback to direct git diff
|
# Fallback to direct git diff
|
||||||
try:
|
try:
|
||||||
cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"]
|
if head_commit:
|
||||||
|
cmd = ["git", "diff", self.base_commit, head_commit]
|
||||||
|
else:
|
||||||
|
cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"]
|
||||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=src)
|
res = subprocess.run(cmd, capture_output=True, text=True, cwd=src)
|
||||||
return res.stdout
|
return res.stdout
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -518,7 +550,7 @@ class LoopRunner:
|
|||||||
def _context_active(self) -> bool:
|
def _context_active(self) -> bool:
|
||||||
return self.worktree_given or self.base_ref is not None
|
return self.worktree_given or self.base_ref is not None
|
||||||
|
|
||||||
def _worktree_context(self) -> str:
|
def _worktree_context(self, remote: bool = False) -> str:
|
||||||
"""The source-root block put in front of every delegated prompt (D6);
|
"""The source-root block put in front of every delegated prompt (D6);
|
||||||
empty without --worktree/--base, so prompts stay byte-identical."""
|
empty without --worktree/--base, so prompts stay byte-identical."""
|
||||||
if not self._context_active():
|
if not self._context_active():
|
||||||
@@ -535,17 +567,23 @@ class LoopRunner:
|
|||||||
count = r.stdout.strip()
|
count = r.stdout.strip()
|
||||||
st = self._git(src, "status", "--porcelain")
|
st = self._git(src, "status", "--porcelain")
|
||||||
dirty = "알 수 없음" if st.returncode != 0 else ("있음" if st.stdout.strip() else "없음")
|
dirty = "알 수 없음" if st.returncode != 0 else ("있음" if st.stdout.strip() else "없음")
|
||||||
|
# a remote reviewer (diff-only) cannot reach this host's paths (B6)
|
||||||
|
src_line = ("- 소스 경로: (오케스트레이터 호스트 전용 — 원격 리뷰어는 접근할 수 없음)\n" if remote
|
||||||
|
else f"- 소스 경로: {src}\n")
|
||||||
|
tail = ("이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n" if remote else
|
||||||
|
"이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n")
|
||||||
return ("[워크트리 컨텍스트]\n"
|
return ("[워크트리 컨텍스트]\n"
|
||||||
f"- 소스 경로: {src}\n"
|
+ src_line
|
||||||
f"- 브랜치: {branch}\n"
|
+ f"- 브랜치: {branch}\n"
|
||||||
f"- base: {base or '(Phase 2 시작 시 HEAD 로 정해짐)'} (출처: {origin})\n"
|
f"- base: {base or '(Phase 2 시작 시 HEAD 로 정해짐)'} (출처: {origin})\n"
|
||||||
f"- head: {head} (base..head 커밋: {count}개, 미커밋 변경: {dirty})\n"
|
f"- head: {head} (base..head 커밋: {count}개, 미커밋 변경: {dirty})\n"
|
||||||
f"- 제어 평면(잡 브리프·리포트·레지스트리): {self.workspace_root}\n"
|
f"- 제어 평면(잡 브리프·리포트·레지스트리): {self.workspace_root}\n"
|
||||||
"이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n")
|
+ tail)
|
||||||
|
|
||||||
def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str) -> Optional[str]:
|
def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str,
|
||||||
|
remote: bool = False) -> Optional[str]:
|
||||||
"""delegate_job with the source-root context block in front of the prompt."""
|
"""delegate_job with the source-root context block in front of the prompt."""
|
||||||
return self.delegate_job(agent_session, agent_type, role, self._worktree_context() + prompt)
|
return self.delegate_job(agent_session, agent_type, role, self._worktree_context(remote) + prompt)
|
||||||
|
|
||||||
def _change_set_state(self) -> str:
|
def _change_set_state(self) -> str:
|
||||||
"""'empty' | 'present' | 'unknown' for the source root against base,
|
"""'empty' | 'present' | 'unknown' for the source root against base,
|
||||||
@@ -580,6 +618,174 @@ class LoopRunner:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Issue #21 item 5: review identity
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _is_remote(self, name: str) -> bool:
|
||||||
|
return self.session_label(name).startswith("remote:")
|
||||||
|
|
||||||
|
def _verdict_recipients(self, state: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Who receives a verdict job - the same rule Phase 3 applies; the
|
||||||
|
creator when there is no reviewer (self-review)."""
|
||||||
|
if self.all_reviewers:
|
||||||
|
names = self.resolve_all_reviewers(state)
|
||||||
|
elif self.reviewer_list:
|
||||||
|
names = [r.strip() for r in self.reviewer_list.split(",") if r.strip()]
|
||||||
|
else:
|
||||||
|
names = []
|
||||||
|
return names or [self.creator]
|
||||||
|
|
||||||
|
def _check_remote_recipients(self, state: Dict[str, Any]) -> bool:
|
||||||
|
"""B14: a verdict recipient must be able to reach the evidence recorded
|
||||||
|
for it. Remote reviewers cannot reach this host's review tree: refused
|
||||||
|
by default, admitted with the snapshot diff as their only evidence
|
||||||
|
under --remote-review diff-only. Checked before any delegation."""
|
||||||
|
remote = [n for n in self._verdict_recipients(state) if self._is_remote(n)]
|
||||||
|
self._remote_recipients = set(remote)
|
||||||
|
if remote and self.remote_review != "diff-only":
|
||||||
|
log_error(f"identity review needs the review tree on this host; remote reviewer(s) {' '.join(remote)} "
|
||||||
|
"cannot verify it. Use local reviewers, or pass --remote-review diff-only")
|
||||||
|
return False
|
||||||
|
if remote:
|
||||||
|
log_warn(f"remote reviewer(s) {' '.join(remote)} admitted with the snapshot diff as their only "
|
||||||
|
"evidence (--remote-review diff-only)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _worktree_tree(self) -> Optional[str]:
|
||||||
|
"""The source worktree's state as a tree, through a temporary index
|
||||||
|
(the real index, status and refs are untouched); None on failure."""
|
||||||
|
fd, idx = tempfile.mkstemp(prefix="mam-review-index-")
|
||||||
|
os.close(fd)
|
||||||
|
os.unlink(idx)
|
||||||
|
env = dict(os.environ, GIT_INDEX_FILE=idx)
|
||||||
|
try:
|
||||||
|
for step in (("read-tree", "HEAD"), ("add", "-A"), ("write-tree",)):
|
||||||
|
r = subprocess.run(["git", "-C", str(self.worktree_root), *step],
|
||||||
|
capture_output=True, text=True, env=env)
|
||||||
|
if r.returncode != 0:
|
||||||
|
log_error(f"review snapshot: git {step[0]} failed in {self.worktree_root}: {r.stderr.strip()}")
|
||||||
|
return None
|
||||||
|
return r.stdout.strip() or None
|
||||||
|
finally:
|
||||||
|
for p in (idx, idx + ".lock"):
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _gitlinks(self, treeish: str) -> Optional[Set[str]]:
|
||||||
|
r = self._git(self.worktree_root, "ls-tree", "-r", "-z", treeish)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
links = set()
|
||||||
|
for ent in r.stdout.split("\0"):
|
||||||
|
meta, _, path = ent.partition("\t")
|
||||||
|
if meta.split(" ")[0] == "160000":
|
||||||
|
links.add(path)
|
||||||
|
return links
|
||||||
|
|
||||||
|
def _identity_now(self) -> Tuple[str, str]:
|
||||||
|
return (self._source_head(), self._worktree_tree() or "")
|
||||||
|
|
||||||
|
def _snapshot(self, n: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""B3: pin the source state of review round n. Uncommitted and
|
||||||
|
untracked (not ignored) files go into a ref-less snapshot commit on
|
||||||
|
top of HEAD. An untracked nested repository cannot be pinned (only a
|
||||||
|
gitlink would be) and is refused."""
|
||||||
|
head = self._source_head()
|
||||||
|
tree = self._worktree_tree() if head else None
|
||||||
|
if not head or not tree:
|
||||||
|
log_error(f"cannot snapshot {self.worktree_root}; refusing to request reviews")
|
||||||
|
return None
|
||||||
|
new_links = self._gitlinks(tree)
|
||||||
|
old_links = self._gitlinks(head)
|
||||||
|
if new_links is None or old_links is None:
|
||||||
|
log_error("cannot list the snapshot's entries; refusing to request reviews")
|
||||||
|
return None
|
||||||
|
untracked_repos = sorted(new_links - old_links)
|
||||||
|
if untracked_repos:
|
||||||
|
log_error(f"untracked nested git repository {' '.join(untracked_repos)} cannot be pinned into the review "
|
||||||
|
"snapshot; commit it, ignore it or move it out")
|
||||||
|
return None
|
||||||
|
dirty = tree != self._git(self.worktree_root, "rev-parse", "HEAD^{tree}").stdout.strip()
|
||||||
|
commit = head
|
||||||
|
if dirty:
|
||||||
|
ident = {"GIT_AUTHOR_NAME": "mam-loop", "GIT_AUTHOR_EMAIL": "mam-loop@localhost",
|
||||||
|
"GIT_COMMITTER_NAME": "mam-loop", "GIT_COMMITTER_EMAIL": "mam-loop@localhost"}
|
||||||
|
c = subprocess.run(["git", "-C", str(self.worktree_root), "commit-tree", tree, "-p", head,
|
||||||
|
"-m", f"mam review snapshot {self.loop_id} #{n}"],
|
||||||
|
capture_output=True, text=True, env=dict(os.environ, **ident))
|
||||||
|
if c.returncode != 0 or not c.stdout.strip():
|
||||||
|
log_error(f"cannot commit the review snapshot: {c.stderr.strip()}")
|
||||||
|
return None
|
||||||
|
commit = c.stdout.strip()
|
||||||
|
return {"head": head, "tree": tree, "dirty": dirty, "review_commit": commit}
|
||||||
|
|
||||||
|
def _open_review_tree(self, snap: Dict[str, Any], n: int) -> Optional[str]:
|
||||||
|
path = Path(tempfile.gettempdir()) / "mam-review" / self.loop_id / str(n)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
r = self._git(self.worktree_root, "worktree", "add", "--detach", str(path), snap["review_commit"])
|
||||||
|
if r.returncode != 0:
|
||||||
|
log_error(f"cannot open the review worktree {path}: {r.stderr.strip()}; refusing to request reviews "
|
||||||
|
"(the isolation is not lowered silently)")
|
||||||
|
return None
|
||||||
|
self._review_paths.append(str(path))
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
def _close_review_tree(self, path: Optional[str]) -> None:
|
||||||
|
if not path or path not in self._review_paths:
|
||||||
|
return
|
||||||
|
self._git(self.worktree_root, "worktree", "remove", "--force", path)
|
||||||
|
self._git(self.worktree_root, "worktree", "prune")
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
self._review_paths.remove(path)
|
||||||
|
|
||||||
|
def _close_all_review_trees(self) -> None:
|
||||||
|
for p in list(self._review_paths):
|
||||||
|
self._close_review_tree(p)
|
||||||
|
|
||||||
|
def _evidence(self, name: str) -> Dict[str, str]:
|
||||||
|
if name in self._remote_recipients:
|
||||||
|
return {"transport": "remote", "evidence": "diff-only", "isolation": "none (remote)"}
|
||||||
|
if self.review_isolation == "worktree":
|
||||||
|
return {"transport": "herdr", "evidence": "review-tree", "isolation": "worktree"}
|
||||||
|
return {"transport": "herdr", "evidence": "source-tree", "isolation": "env"}
|
||||||
|
|
||||||
|
def _identity_block(self, snap: Dict[str, Any], path: Optional[str], remote: bool) -> str:
|
||||||
|
lines = ["[리뷰 identity]",
|
||||||
|
f"- loop_id: {self.loop_id}",
|
||||||
|
f"- base: {self.base_commit}",
|
||||||
|
f"- head: {snap['head']}",
|
||||||
|
f"- tree: {snap['tree']}"]
|
||||||
|
if snap["dirty"]:
|
||||||
|
lines.append(f"- 미커밋 변경 포함 스냅샷 커밋: {snap['review_commit']}")
|
||||||
|
if remote:
|
||||||
|
lines.append("당신의 호스트에는 이 트리가 없다. 자기 체크아웃이나 다른 커밋에서 실행한 결과를 판정 근거로 "
|
||||||
|
"삼지 말 것. 근거는 아래 diff 뿐이며, 리포트에 'diff 만으로 검토' 를 밝힐 것.")
|
||||||
|
else:
|
||||||
|
py = self.workspace_root / ".venv" / "bin" / "python"
|
||||||
|
py_cmd = str(py) if py.exists() else "python3"
|
||||||
|
lines.append(f"- 검증 경로: {path}")
|
||||||
|
lines.append("이 경로에서만 검증하고 커밋·브랜치 변경은 하지 말 것. 테스트는 "
|
||||||
|
f"`PYTHONDONTWRITEBYTECODE=1 {py_cmd} -m pytest -p no:cacheprovider "
|
||||||
|
"--basetemp=<검증 경로 밖의 임시 디렉터리>` 로 실행한다.")
|
||||||
|
return "\n".join(lines) + "\n\n"
|
||||||
|
|
||||||
|
def _record_round(self, n: int, snap: Dict[str, Any], path: Optional[str], jobs: Dict[str, str],
|
||||||
|
verdicts: Dict[str, str], stale: bool) -> None:
|
||||||
|
self._rounds.append({
|
||||||
|
"iteration": n, "base_sha": self.base_commit, "head_sha": snap["head"], "tree_sha": snap["tree"],
|
||||||
|
"review_commit": snap["review_commit"], "isolation": self.review_isolation,
|
||||||
|
"review_path": path or "", "jobs": jobs, "verdicts": verdicts,
|
||||||
|
"reviewers": {r: self._evidence(r) for r in jobs}, "stale": stale,
|
||||||
|
})
|
||||||
|
self._record_checkpoint(iteration=n)
|
||||||
|
|
||||||
|
def _round_is_stale(self, snap: Dict[str, Any]) -> bool:
|
||||||
|
"""B7: the source changed while the round was being reviewed."""
|
||||||
|
return self._identity_now() != (snap["head"], snap["tree"])
|
||||||
|
|
||||||
def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
|
def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
|
||||||
data = {
|
data = {
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
@@ -592,12 +798,16 @@ class LoopRunner:
|
|||||||
"review_only": self.review_only,
|
"review_only": self.review_only,
|
||||||
"worktree_root": str(self.worktree_root),
|
"worktree_root": str(self.worktree_root),
|
||||||
"head_commit": self._source_head() if self._context_active() else "",
|
"head_commit": self._source_head() if self._context_active() else "",
|
||||||
|
"loop_id": self.loop_id,
|
||||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
}
|
}
|
||||||
|
if self._context_active():
|
||||||
|
data["review_rounds"] = self._rounds
|
||||||
checkpoint.save_checkpoint(self.checkpoint_file, data)
|
checkpoint.save_checkpoint(self.checkpoint_file, data)
|
||||||
|
|
||||||
def release_guard(self) -> None:
|
def release_guard(self) -> None:
|
||||||
"""Release MAM_LOOP_MARKER and clean up freeze dir if owned."""
|
"""Release MAM_LOOP_MARKER and clean up freeze dir if owned."""
|
||||||
|
self._close_all_review_trees()
|
||||||
marker = os.environ.get("MAM_LOOP_MARKER") or str(self.mam_dir / "loop-guard-active")
|
marker = os.environ.get("MAM_LOOP_MARKER") or str(self.mam_dir / "loop-guard-active")
|
||||||
if os.path.isfile(marker):
|
if os.path.isfile(marker):
|
||||||
try:
|
try:
|
||||||
@@ -614,6 +824,12 @@ class LoopRunner:
|
|||||||
shutil.rmtree(freeze_dir, ignore_errors=True)
|
shutil.rmtree(freeze_dir, ignore_errors=True)
|
||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
|
try:
|
||||||
|
return self._run_body()
|
||||||
|
finally:
|
||||||
|
self._close_all_review_trees() # a return or exit mid-round leaves no review tree
|
||||||
|
|
||||||
|
def _run_body(self) -> int:
|
||||||
self.parse_args()
|
self.parse_args()
|
||||||
|
|
||||||
atexit.register(self.release_guard)
|
atexit.register(self.release_guard)
|
||||||
@@ -630,6 +846,7 @@ class LoopRunner:
|
|||||||
signal.signal(signal.SIGTERM, _handle_signal)
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
|
||||||
log_info("Initializing multi-agent-mux-loop controller...")
|
log_info("Initializing multi-agent-mux-loop controller...")
|
||||||
|
log_info(f"Loop id: {self.loop_id}")
|
||||||
log_info(f"Target Agent: {self.creator}")
|
log_info(f"Target Agent: {self.creator}")
|
||||||
log_info(f"Task Goal: {self.task}")
|
log_info(f"Task Goal: {self.task}")
|
||||||
|
|
||||||
@@ -672,6 +889,11 @@ class LoopRunner:
|
|||||||
|
|
||||||
log_info(f"Resolved Planner session: {planner_session}")
|
log_info(f"Resolved Planner session: {planner_session}")
|
||||||
|
|
||||||
|
# Issue #21 item 5 (B14): every verdict recipient must reach the
|
||||||
|
# evidence recorded for it - checked before anything is delegated.
|
||||||
|
if self._context_active() and not self._check_remote_recipients(state):
|
||||||
|
return 1
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 1: PLANNING & DISCUSSIONS
|
# PHASE 1: PLANNING & DISCUSSIONS
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
@@ -780,6 +1002,7 @@ class LoopRunner:
|
|||||||
rebut_total_budget = self.max_rebut * self.max_loop
|
rebut_total_budget = self.max_rebut * self.max_loop
|
||||||
|
|
||||||
final_passed_reviewers: Dict[str, Tuple[str, Path]] = {}
|
final_passed_reviewers: Dict[str, Tuple[str, Path]] = {}
|
||||||
|
identity = self._context_active()
|
||||||
|
|
||||||
for loop_count in range(1, self.max_loop + 1):
|
for loop_count in range(1, self.max_loop + 1):
|
||||||
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
|
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
|
||||||
@@ -788,16 +1011,47 @@ class LoopRunner:
|
|||||||
if not self._change_set_allows_review():
|
if not self._change_set_allows_review():
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Issue #21 item 5: pin this round (B3), open its review tree (B4)
|
||||||
|
snap: Optional[Dict[str, Any]] = None
|
||||||
|
review_path: Optional[str] = None
|
||||||
|
if identity:
|
||||||
|
final_passed_reviewers.clear() # B8: only this round's approvals can be promoted
|
||||||
|
snap = self._snapshot(loop_count)
|
||||||
|
if snap is None:
|
||||||
|
return 1
|
||||||
|
if self.review_isolation == "worktree":
|
||||||
|
review_path = self._open_review_tree(snap, loop_count)
|
||||||
|
if review_path is None:
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
review_path = str(self.worktree_root)
|
||||||
|
|
||||||
if not reviewers:
|
if not reviewers:
|
||||||
# Creator Self-Review
|
# Creator Self-Review
|
||||||
log_warn("No reviewers specified. Conducting Creator Self-Review...")
|
log_warn("No reviewers specified. Conducting Creator Self-Review...")
|
||||||
sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요."
|
sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요."
|
||||||
sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt)
|
sr_remote = self.creator in self._remote_recipients
|
||||||
|
if identity:
|
||||||
|
sr_prompt = self._identity_block(snap, review_path, sr_remote) + sr_prompt
|
||||||
|
sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt, remote=sr_remote)
|
||||||
if not sr_jid:
|
if not sr_jid:
|
||||||
log_error("Failed to register Self-Review job.")
|
log_error("Failed to register Self-Review job.")
|
||||||
return 1
|
return 1
|
||||||
self.wait_for_job(sr_jid)
|
self.wait_for_job(sr_jid)
|
||||||
sr_report = self.find_report(sr_jid)
|
sr_report = self.find_report(sr_jid)
|
||||||
|
if identity:
|
||||||
|
if self.review_isolation == "worktree":
|
||||||
|
self._close_review_tree(review_path)
|
||||||
|
sr_stale = self._round_is_stale(snap)
|
||||||
|
sr_verdict = "PASS" if (sr_report and parser.has_verdict(sr_report, "PASS")) else "NOT PASS"
|
||||||
|
self._record_round(loop_count, snap, review_path, {self.creator: sr_jid},
|
||||||
|
{self.creator: sr_verdict}, sr_stale)
|
||||||
|
if sr_stale:
|
||||||
|
log_warn("source changed during review; the self-review is discarded, re-reviewing")
|
||||||
|
if loop_count == self.max_loop:
|
||||||
|
log_error("the source changed during review in the last iteration; no verdict to accept")
|
||||||
|
return 1
|
||||||
|
continue
|
||||||
if sr_report and parser.has_verdict(sr_report, "PASS") and not parser.has_verdict(sr_report, "NOT PASS"):
|
if sr_report and parser.has_verdict(sr_report, "PASS") and not parser.has_verdict(sr_report, "NOT PASS"):
|
||||||
log_success("Self-Review PASS.")
|
log_success("Self-Review PASS.")
|
||||||
break
|
break
|
||||||
@@ -809,20 +1063,32 @@ class LoopRunner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
log_info(f"Active reviewers: {' '.join(reviewers)}")
|
log_info(f"Active reviewers: {' '.join(reviewers)}")
|
||||||
changes_diff = self.collect_changes_diff()
|
# B13: in identity mode the reviewers' diff is the snapshot's, so the
|
||||||
|
# evidence they get is exactly what the verdict is bound to.
|
||||||
|
changes_diff = self.collect_changes_diff(snap["review_commit"] if identity else None)
|
||||||
|
diff_only = [r for r in reviewers if r in self._remote_recipients]
|
||||||
|
if identity and diff_only and ("!!! DIFF TRUNCATED !!!" in changes_diff
|
||||||
|
or "!!! CHANGE SET UNAVAILABLE !!!" in changes_diff):
|
||||||
|
log_error(f"the snapshot diff is truncated/unavailable; diff-only remote reviewer(s) "
|
||||||
|
f"{' '.join(diff_only)} would have no evidence")
|
||||||
|
return 1
|
||||||
|
|
||||||
review_jobs: List[Tuple[str, str]] = [] # (rev, job_id)
|
review_jobs: List[Tuple[str, str]] = [] # (rev, job_id)
|
||||||
for rev in reviewers:
|
for rev in reviewers:
|
||||||
log_info(f"Requesting code review from Reviewer '{rev}'...")
|
log_info(f"Requesting code review from Reviewer '{rev}'...")
|
||||||
r_type = self.resolve_agent_type(rev, state)
|
r_type = self.resolve_agent_type(rev, state)
|
||||||
r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}"
|
r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}"
|
||||||
r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt)
|
r_remote = rev in self._remote_recipients
|
||||||
|
if identity:
|
||||||
|
r_prompt = self._identity_block(snap, review_path, r_remote) + r_prompt
|
||||||
|
r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt, remote=r_remote)
|
||||||
if not r_jid:
|
if not r_jid:
|
||||||
log_error(f"Failed to register review job for '{rev}'.")
|
log_error(f"Failed to register review job for '{rev}'.")
|
||||||
return 1
|
return 1
|
||||||
review_jobs.append((rev, r_jid))
|
review_jobs.append((rev, r_jid))
|
||||||
|
|
||||||
all_passed = True
|
all_passed = True
|
||||||
|
round_verdicts: Dict[str, str] = {}
|
||||||
fail_revs: List[str] = []
|
fail_revs: List[str] = []
|
||||||
fail_reports: List[Path] = []
|
fail_reports: List[Path] = []
|
||||||
feedback_parts: List[str] = []
|
feedback_parts: List[str] = []
|
||||||
@@ -830,12 +1096,14 @@ class LoopRunner:
|
|||||||
for rev, jid in review_jobs:
|
for rev, jid in review_jobs:
|
||||||
if not self.wait_for_job(jid):
|
if not self.wait_for_job(jid):
|
||||||
log_warn(f"Reviewer '{rev}' job crashed.")
|
log_warn(f"Reviewer '{rev}' job crashed.")
|
||||||
|
round_verdicts[rev] = "crashed"
|
||||||
all_passed = False
|
all_passed = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
r_file = self.find_report(jid)
|
r_file = self.find_report(jid)
|
||||||
if not r_file or not r_file.exists():
|
if not r_file or not r_file.exists():
|
||||||
log_warn(f"Reviewer '{rev}' report not found. Counting as NOT PASS.")
|
log_warn(f"Reviewer '{rev}' report not found. Counting as NOT PASS.")
|
||||||
|
round_verdicts[rev] = "missing"
|
||||||
all_passed = False
|
all_passed = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -844,15 +1112,34 @@ class LoopRunner:
|
|||||||
|
|
||||||
if verdict != "PASS":
|
if verdict != "PASS":
|
||||||
log_warn(f"Reviewer '{rev}': NOT PASS")
|
log_warn(f"Reviewer '{rev}': NOT PASS")
|
||||||
|
round_verdicts[rev] = "NOT PASS"
|
||||||
all_passed = False
|
all_passed = False
|
||||||
fail_revs.append(rev)
|
fail_revs.append(rev)
|
||||||
fail_reports.append(r_file)
|
fail_reports.append(r_file)
|
||||||
feedback_parts.append(f"\n--- Reviewer ({rev}) Feedback ---\n{content}")
|
feedback_parts.append(f"\n--- Reviewer ({rev}) Feedback ---\n{content}")
|
||||||
else:
|
else:
|
||||||
log_success(f"Reviewer '{rev}': PASS")
|
log_success(f"Reviewer '{rev}': PASS")
|
||||||
|
round_verdicts[rev] = "PASS"
|
||||||
final_passed_reviewers[rev] = (jid, r_file)
|
final_passed_reviewers[rev] = (jid, r_file)
|
||||||
|
|
||||||
|
if identity:
|
||||||
|
# B7: judge the round against the source as it is now
|
||||||
|
if self.review_isolation == "worktree":
|
||||||
|
self._close_review_tree(review_path)
|
||||||
|
stale = self._round_is_stale(snap)
|
||||||
|
self._record_round(loop_count, snap, review_path, dict(review_jobs), round_verdicts, stale)
|
||||||
|
if stale:
|
||||||
|
final_passed_reviewers.clear()
|
||||||
|
log_warn(f"source changed during review (head {snap['head'][:12]}, tree {snap['tree'][:12]}); "
|
||||||
|
"approvals discarded, re-reviewing")
|
||||||
|
if loop_count == self.max_loop:
|
||||||
|
log_error("the source changed during review in the last iteration; no approval to accept")
|
||||||
|
return 1
|
||||||
|
continue
|
||||||
|
|
||||||
if all_passed:
|
if all_passed:
|
||||||
|
if identity:
|
||||||
|
self._accepted_round = self._rounds[-1]
|
||||||
log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.")
|
log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -910,7 +1197,8 @@ class LoopRunner:
|
|||||||
log_info(f"Re-adjudication: returning rebuttal to Reviewer '{rt}'...")
|
log_info(f"Re-adjudication: returning rebuttal to Reviewer '{rt}'...")
|
||||||
rt_type = self.resolve_agent_type(rt, state)
|
rt_type = self.resolve_agent_type(rt, state)
|
||||||
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
|
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
|
||||||
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt)
|
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt,
|
||||||
|
remote=rt in self._remote_recipients)
|
||||||
if not adj_jid or not self.wait_for_job(adj_jid):
|
if not adj_jid or not self.wait_for_job(adj_jid):
|
||||||
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
|
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
|
||||||
overruled_revs.append(rt)
|
overruled_revs.append(rt)
|
||||||
@@ -956,6 +1244,13 @@ class LoopRunner:
|
|||||||
tmp_plan.write_text(self.current_plan, encoding="utf-8")
|
tmp_plan.write_text(self.current_plan, encoding="utf-8")
|
||||||
tmp_plan.replace(plan_dest_dir / f"plan-{self.plan_job_id}.md")
|
tmp_plan.replace(plan_dest_dir / f"plan-{self.plan_job_id}.md")
|
||||||
|
|
||||||
|
if final_passed_reviewers and identity and self._accepted_round:
|
||||||
|
# B10: an approval is promoted only for the source it approved
|
||||||
|
acc = self._accepted_round
|
||||||
|
if self._identity_now() != (acc["head_sha"], acc["tree_sha"]):
|
||||||
|
log_error("source changed after approval; not promoting")
|
||||||
|
return 1
|
||||||
|
|
||||||
if final_passed_reviewers:
|
if final_passed_reviewers:
|
||||||
log_info("Promoting final review reports to durable location...")
|
log_info("Promoting final review reports to durable location...")
|
||||||
for rev, (jid, rep_file) in final_passed_reviewers.items():
|
for rev, (jid, rep_file) in final_passed_reviewers.items():
|
||||||
@@ -965,6 +1260,17 @@ class LoopRunner:
|
|||||||
tmp_dest = dest_dir / f"report-{jid}.md.tmp"
|
tmp_dest = dest_dir / f"report-{jid}.md.tmp"
|
||||||
shutil.copyfile(rep_file, tmp_dest)
|
shutil.copyfile(rep_file, tmp_dest)
|
||||||
tmp_dest.replace(dest_file)
|
tmp_dest.replace(dest_file)
|
||||||
|
if identity and self._accepted_round:
|
||||||
|
acc = self._accepted_round
|
||||||
|
ev = self._evidence(rev)
|
||||||
|
sidecar = {"loop_id": self.loop_id, "base_sha": acc["base_sha"], "head_sha": acc["head_sha"],
|
||||||
|
"tree_sha": acc["tree_sha"], "review_commit": acc["review_commit"],
|
||||||
|
"reviewer": rev, "job_id": jid, "verdict": "PASS", **ev,
|
||||||
|
"verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
||||||
|
tmp_side = dest_dir / f"report-{jid}.identity.json.tmp"
|
||||||
|
tmp_side.write_text(json.dumps(sidecar, indent=2), encoding="utf-8")
|
||||||
|
tmp_side.replace(dest_dir / f"report-{jid}.identity.json")
|
||||||
|
log_info(f" {rev}: report-{jid}.md (evidence: {ev['evidence']})")
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
if self.cleanup:
|
if self.cleanup:
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ _mam_untracked_diff() {
|
|||||||
mam_collect_changes_diff() {
|
mam_collect_changes_diff() {
|
||||||
local repo_root="${1:-}"
|
local repo_root="${1:-}"
|
||||||
local base_commit="${2:-}"
|
local base_commit="${2:-}"
|
||||||
|
# Optional third argument (Issue #21 item 5): a commit - the review
|
||||||
|
# snapshot - to diff against base, commit to commit. The snapshot already
|
||||||
|
# holds the untracked files, so no untracked scan; nothing falls back to
|
||||||
|
# the live tree.
|
||||||
|
local head_commit="${3:-}"
|
||||||
|
|
||||||
if [ -z "$repo_root" ]; then
|
if [ -z "$repo_root" ]; then
|
||||||
printf '%s\nreason: repo_root parameter is required\n' "$MAM_DIFF_UNAVAILABLE_MARKER"
|
printf '%s\nreason: repo_root parameter is required\n' "$MAM_DIFF_UNAVAILABLE_MARKER"
|
||||||
@@ -70,6 +75,12 @@ mam_collect_changes_diff() {
|
|||||||
raw_diff=$(
|
raw_diff=$(
|
||||||
cd -P "$repo_root" 2>/dev/null || exit 3
|
cd -P "$repo_root" 2>/dev/null || exit 3
|
||||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 4
|
git rev-parse --git-dir >/dev/null 2>&1 || exit 4
|
||||||
|
if [ -n "$head_commit" ]; then
|
||||||
|
git cat-file -e "${base_commit}^{commit}" 2>/dev/null || exit 6
|
||||||
|
git cat-file -e "${head_commit}^{commit}" 2>/dev/null || exit 6
|
||||||
|
git diff "$base_commit" "$head_commit" || exit 5
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then
|
if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then
|
||||||
git diff "$base_commit" || exit 5
|
git diff "$base_commit" || exit 5
|
||||||
else
|
else
|
||||||
@@ -86,6 +97,8 @@ mam_collect_changes_diff() {
|
|||||||
reason="not a git repository: $repo_root"
|
reason="not a git repository: $repo_root"
|
||||||
elif [ "$rc" -eq 5 ]; then
|
elif [ "$rc" -eq 5 ]; then
|
||||||
reason="git diff command failed"
|
reason="git diff command failed"
|
||||||
|
elif [ "$rc" -eq 6 ]; then
|
||||||
|
reason="base '$base_commit' or head '$head_commit' is not a commit"
|
||||||
fi
|
fi
|
||||||
printf '%s\nreason: %s\n' "$MAM_DIFF_UNAVAILABLE_MARKER" "$reason"
|
printf '%s\nreason: %s\n' "$MAM_DIFF_UNAVAILABLE_MARKER" "$reason"
|
||||||
return 2
|
return 2
|
||||||
@@ -104,6 +117,10 @@ mam_collect_changes_diff() {
|
|||||||
local stat_summary=""
|
local stat_summary=""
|
||||||
stat_summary=$(
|
stat_summary=$(
|
||||||
cd -P "$repo_root" 2>/dev/null || exit 0
|
cd -P "$repo_root" 2>/dev/null || exit 0
|
||||||
|
if [ -n "$head_commit" ]; then
|
||||||
|
git diff "$base_commit" "$head_commit" --stat 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then
|
if [ -n "$base_commit" ] && git cat-file -e "${base_commit}^{commit}" 2>/dev/null; then
|
||||||
git diff "$base_commit" --stat 2>/dev/null || true
|
git diff "$base_commit" --stat 2>/dev/null || true
|
||||||
else
|
else
|
||||||
@@ -117,7 +134,11 @@ mam_collect_changes_diff() {
|
|||||||
printf 'The change set is %s bytes / %s lines, over the review limit (%s bytes / %s lines).\n' \
|
printf 'The change set is %s bytes / %s lines, over the review limit (%s bytes / %s lines).\n' \
|
||||||
"$byte_cnt" "$line_cnt" "$MAM_DIFF_MAX_BYTES" "$MAM_DIFF_MAX_LINES"
|
"$byte_cnt" "$line_cnt" "$MAM_DIFF_MAX_BYTES" "$MAM_DIFF_MAX_LINES"
|
||||||
printf 'Only the file-level summary is shown below. You have NOT been shown the\n'
|
printf 'Only the file-level summary is shown below. You have NOT been shown the\n'
|
||||||
printf 'full change set -- inspect the working tree directly before voting.\n\n'
|
if [ -n "$head_commit" ]; then
|
||||||
|
printf 'full change set -- inspect the review snapshot (%s) directly before voting.\n\n' "$head_commit"
|
||||||
|
else
|
||||||
|
printf 'full change set -- inspect the working tree directly before voting.\n\n'
|
||||||
|
fi
|
||||||
printf '%s\n\n' "$stat_summary"
|
printf '%s\n\n' "$stat_summary"
|
||||||
printf '%s\n' "$MAM_DIFF_TRUNCATED_MARKER"
|
printf '%s\n' "$MAM_DIFF_TRUNCATED_MARKER"
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ PLANNER_SESSION_OVERRIDE=""
|
|||||||
LOOP_WORKTREE=""
|
LOOP_WORKTREE=""
|
||||||
LOOP_BASE=""
|
LOOP_BASE=""
|
||||||
LOOP_REVIEW_ONLY=0
|
LOOP_REVIEW_ONLY=0
|
||||||
|
LOOP_REVIEW_ISOLATION=""
|
||||||
|
LOOP_REMOTE_REVIEW=""
|
||||||
|
|
||||||
# Print usage instructions
|
# Print usage instructions
|
||||||
usage() {
|
usage() {
|
||||||
@@ -53,6 +55,8 @@ usage() {
|
|||||||
echo " --worktree <path> Source worktree for diff/base/head (control plane stays here)"
|
echo " --worktree <path> Source worktree for diff/base/head (control plane stays here)"
|
||||||
echo " --base <ref> Pin the review diff base (must be an ancestor of the source HEAD)"
|
echo " --base <ref> Pin the review diff base (must be an ancestor of the source HEAD)"
|
||||||
echo " --review-only Skip implementation; review <base>..HEAD (requires --base)"
|
echo " --review-only Skip implementation; review <base>..HEAD (requires --base)"
|
||||||
|
echo " --review-isolation <v> Review tree with --worktree/--base: worktree (default) or env"
|
||||||
|
echo " --remote-review <v> Remote reviewers with --worktree/--base: refuse (default) or diff-only"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +96,8 @@ while [[ "$#" -gt 0 ]]; do
|
|||||||
--worktree) LOOP_WORKTREE="$2"; shift 2 ;;
|
--worktree) LOOP_WORKTREE="$2"; shift 2 ;;
|
||||||
--base) LOOP_BASE="$2"; shift 2 ;;
|
--base) LOOP_BASE="$2"; shift 2 ;;
|
||||||
--review-only) LOOP_REVIEW_ONLY=1; shift ;;
|
--review-only) LOOP_REVIEW_ONLY=1; shift ;;
|
||||||
|
--review-isolation) LOOP_REVIEW_ISOLATION="$2"; shift 2 ;;
|
||||||
|
--remote-review) LOOP_REMOTE_REVIEW="$2"; shift 2 ;;
|
||||||
-h|--help) usage ;;
|
-h|--help) usage ;;
|
||||||
*) echo "Unknown option: $1"; usage ;;
|
*) echo "Unknown option: $1"; usage ;;
|
||||||
esac
|
esac
|
||||||
@@ -117,8 +123,9 @@ fi
|
|||||||
# diffs the checkout it runs in; the worktree/base options are Python-only.
|
# diffs the checkout it runs in; the worktree/base options are Python-only.
|
||||||
# Checked before the freeze and the loop lock (log_* are not defined yet).
|
# Checked before the freeze and the loop lock (log_* are not defined yet).
|
||||||
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|
||||||
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ]; }; then
|
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ] \
|
||||||
echo "ERROR: --worktree/--base/--review-only require the Python loop (MAM_LOOP_PY=1)" >&2
|
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ]; }; then
|
||||||
|
echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review require the Python loop (MAM_LOOP_PY=1)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""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"}]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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 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)
|
||||||
|
old_sh = tmp_path / "diff_collect_old.sh"
|
||||||
|
old_sh.write_text(old.stdout)
|
||||||
|
new2 = _diff_collect(wt, base)
|
||||||
|
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())
|
||||||
|
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
|
||||||
Reference in new issue
Block a user