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
+216
-32
No files matched your search
@@ -27,6 +27,10 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from lib_py.loop import checkpoint, eager, parser
|
||||
|
||||
UNAVAILABLE_MARKER = "!!! CHANGE SET UNAVAILABLE !!!" # diff_collect.sh MAM_DIFF_UNAVAILABLE_MARKER
|
||||
SELF_REVIEW_PROMPT = ("작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. "
|
||||
"리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요.")
|
||||
|
||||
# ANSI colors matching run_loop.sh
|
||||
_COLOR_INFO = "\033[1;34m[*]\033[0m"
|
||||
_COLOR_SUCCESS = "\033[1;32m[✓]\033[0m"
|
||||
@@ -531,6 +535,11 @@ class LoopRunner:
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src)
|
||||
if res.returncode == 0:
|
||||
return res.stdout
|
||||
if head_commit:
|
||||
# the snapshot's evidence is not replaced by anything else (B13):
|
||||
# the collector's UNAVAILABLE output is passed on as it is
|
||||
return res.stdout if UNAVAILABLE_MARKER in res.stdout else (
|
||||
f"{UNAVAILABLE_MARKER}\nreason: diff collector failed (rc {res.returncode})\n")
|
||||
# Fallback to direct git diff
|
||||
try:
|
||||
if head_commit:
|
||||
@@ -538,6 +547,8 @@ class LoopRunner:
|
||||
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)
|
||||
if head_commit and res.returncode != 0:
|
||||
return f"{UNAVAILABLE_MARKER}\nreason: git diff failed (rc {res.returncode})\n"
|
||||
return res.stdout
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -550,9 +561,11 @@ class LoopRunner:
|
||||
def _context_active(self) -> bool:
|
||||
return self.worktree_given or self.base_ref is not None
|
||||
|
||||
def _worktree_context(self, remote: bool = False) -> str:
|
||||
def _worktree_context(self, remote: bool = False, purpose: str = "work") -> str:
|
||||
"""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.
|
||||
purpose: 'work' (creator/planner), 'review' (a verdict job, whose
|
||||
target is the [리뷰 identity] path) or 'adjudication' (a re-hearing)."""
|
||||
if not self._context_active():
|
||||
return ""
|
||||
src = self.worktree_root
|
||||
@@ -570,8 +583,21 @@ class LoopRunner:
|
||||
# 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")
|
||||
if purpose == "review" and remote:
|
||||
tail = "이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n"
|
||||
elif purpose == "review" and self.review_isolation == "worktree":
|
||||
# one verification target per prompt (loop B review B-2)
|
||||
tail = ("리뷰 검증은 아래 [리뷰 identity] 의 검증 경로에서만 한다. 소스 경로는 creator 의 작업 트리이며 "
|
||||
"리뷰어는 거기서 실행·수정하지 않는다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n")
|
||||
elif purpose == "review":
|
||||
tail = ("리뷰 검증은 아래 [리뷰 identity] 의 검증 경로(= 위 소스 경로)에서 하며, 거기서 수정·커밋하지 않는다. "
|
||||
"브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n")
|
||||
elif purpose == "adjudication" and remote:
|
||||
tail = "이 잡은 아래 반론의 재심이다. 판정 근거는 직전 리뷰에서 받은 SHA·diff 와 아래 반론 본문이다.\n\n"
|
||||
elif remote:
|
||||
tail = "이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n"
|
||||
else:
|
||||
tail = "이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n"
|
||||
return ("[워크트리 컨텍스트]\n"
|
||||
+ src_line
|
||||
+ f"- 브랜치: {branch}\n"
|
||||
@@ -581,9 +607,9 @@ class LoopRunner:
|
||||
+ tail)
|
||||
|
||||
def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str,
|
||||
remote: bool = False) -> Optional[str]:
|
||||
remote: bool = False, purpose: str = "work") -> Optional[str]:
|
||||
"""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(remote) + prompt)
|
||||
return self.delegate_job(agent_session, agent_type, role, self._worktree_context(remote, purpose) + prompt)
|
||||
|
||||
def _change_set_state(self) -> str:
|
||||
"""'empty' | 'present' | 'unknown' for the source root against base,
|
||||
@@ -653,7 +679,17 @@ class LoopRunner:
|
||||
|
||||
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."""
|
||||
(the real index, status and refs are untouched); None on failure.
|
||||
A dirty submodule is a failure: the tree records only its gitlink, so
|
||||
its uncommitted content would be neither reviewed nor pinned."""
|
||||
dirty_subs = self._dirty_submodules()
|
||||
if dirty_subs is None:
|
||||
log_error(f"review snapshot: cannot check the submodules of {self.worktree_root}")
|
||||
return None
|
||||
if dirty_subs:
|
||||
log_error(f"review snapshot: submodule(s) with uncommitted or untracked content: {' '.join(dirty_subs)}; "
|
||||
"the snapshot records only their commit - commit or clean them first")
|
||||
return None
|
||||
fd, idx = tempfile.mkstemp(prefix="mam-review-index-")
|
||||
os.close(fd)
|
||||
os.unlink(idx)
|
||||
@@ -673,6 +709,18 @@ class LoopRunner:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _dirty_submodules(self) -> Optional[List[str]]:
|
||||
"""Checked-out submodules (recursively) with uncommitted or untracked
|
||||
content; None when the check itself fails. Each submodule is asked
|
||||
directly with --ignore-submodules=none, so no submodule.*.ignore or
|
||||
diff.ignoreSubmodules setting can hide it."""
|
||||
script = ('s=$(git status --porcelain --ignore-submodules=none) || exit 1; '
|
||||
'[ -z "$s" ] || printf "%s\\n" "$displaypath"')
|
||||
r = self._git(self.worktree_root, "submodule", "foreach", "--recursive", "--quiet", script)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return [line for line in r.stdout.splitlines() if line.strip()]
|
||||
|
||||
def _gitlinks(self, treeish: str) -> Optional[Set[str]]:
|
||||
r = self._git(self.worktree_root, "ls-tree", "-r", "-z", treeish)
|
||||
if r.returncode != 0:
|
||||
@@ -735,11 +783,18 @@ class LoopRunner:
|
||||
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")
|
||||
r = self._git(self.worktree_root, "worktree", "remove", "--force", path)
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if r.returncode != 0:
|
||||
# only when remove failed: prune drops this entry (its directory is
|
||||
# gone now) - and any other stale one, which is why it is not routine
|
||||
self._git(self.worktree_root, "worktree", "prune")
|
||||
self._review_paths.remove(path)
|
||||
try:
|
||||
os.rmdir(os.path.dirname(path)) # TMPDIR/mam-review/<loop_id>, once empty
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _close_all_review_trees(self) -> None:
|
||||
for p in list(self._review_paths):
|
||||
@@ -1003,6 +1058,13 @@ class LoopRunner:
|
||||
|
||||
final_passed_reviewers: Dict[str, Tuple[str, Path]] = {}
|
||||
identity = self._context_active()
|
||||
# Identity mode: a creator self-review is a verdict like any other, so it
|
||||
# takes the reviewer path - snapshot diff, evidence check, B10 re-check
|
||||
# and sidecar (loop B review B-R2/B-R3). A NOT PASS re-reviews, as before.
|
||||
self_review = identity and not reviewers
|
||||
if self_review:
|
||||
log_warn("No reviewers specified. Conducting Creator Self-Review...")
|
||||
reviewers = [self.creator]
|
||||
|
||||
for loop_count in range(1, self.max_loop + 1):
|
||||
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
|
||||
@@ -1029,29 +1091,13 @@ class LoopRunner:
|
||||
if not reviewers:
|
||||
# Creator Self-Review
|
||||
log_warn("No reviewers specified. Conducting Creator Self-Review...")
|
||||
sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요."
|
||||
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)
|
||||
sr_prompt = SELF_REVIEW_PROMPT
|
||||
sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt)
|
||||
if not sr_jid:
|
||||
log_error("Failed to register Self-Review job.")
|
||||
return 1
|
||||
self.wait_for_job(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"):
|
||||
log_success("Self-Review PASS.")
|
||||
break
|
||||
@@ -1077,11 +1123,15 @@ class LoopRunner:
|
||||
for rev in reviewers:
|
||||
log_info(f"Requesting code review from Reviewer '{rev}'...")
|
||||
r_type = self.resolve_agent_type(rev, state)
|
||||
if self_review:
|
||||
r_prompt = f"{SELF_REVIEW_PROMPT} 변경분:\n{changes_diff}"
|
||||
else:
|
||||
r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}"
|
||||
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)
|
||||
r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt, remote=r_remote,
|
||||
purpose="review" if identity else "work")
|
||||
if not r_jid:
|
||||
log_error(f"Failed to register review job for '{rev}'.")
|
||||
return 1
|
||||
@@ -1143,6 +1193,13 @@ class LoopRunner:
|
||||
log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.")
|
||||
break
|
||||
|
||||
if self_review:
|
||||
log_warn("Self-Review NOT PASS.")
|
||||
if loop_count == self.max_loop:
|
||||
log_error("Reached max loop count. Self-review loop aborted with failures.")
|
||||
return 1
|
||||
continue
|
||||
|
||||
if loop_count == self.max_loop:
|
||||
log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.")
|
||||
return 1
|
||||
@@ -1198,7 +1255,7 @@ class LoopRunner:
|
||||
rt_type = self.resolve_agent_type(rt, state)
|
||||
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
|
||||
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt,
|
||||
remote=rt in self._remote_recipients)
|
||||
remote=rt in self._remote_recipients, purpose="adjudication")
|
||||
if not adj_jid or not self.wait_for_job(adj_jid):
|
||||
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
|
||||
overruled_revs.append(rt)
|
||||
|
||||
@@ -200,12 +200,12 @@ sequenceDiagram
|
||||
| **Source root** | `--worktree <path>` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). |
|
||||
| **Diff base** | `--base <ref>` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. |
|
||||
| **Review only** | `--review-only` | Phase 2(구현 위임)를 건너뛰고 `<base>..HEAD` 를 리뷰합니다. `--base` 필수, `--plan` 과 함께 쓸 수 없습니다. 수정·순응 잡은 여전히 creator 에게 갑니다. |
|
||||
| **Review isolation** | `--review-isolation worktree\|env` | `--worktree`/`--base` 에서만 의미. `worktree`(기본): 라운드마다 스냅샷의 detached 리뷰 워크트리(TMPDIR)에서 검증합니다. `env`: 소스 루트에서 `PYTHONDONTWRITEBYTECODE=1 … -p no:cacheprovider --basetemp=<밖>` 지침으로 검증합니다. 리뷰 워크트리를 열 수 없으면 조용히 낮추지 않고 exit 1. |
|
||||
| **Review isolation** | `--review-isolation worktree\|env` | `--worktree`/`--base` 에서만 의미. `worktree`(기본): 라운드마다 스냅샷의 detached 리뷰 워크트리(TMPDIR)에서 검증합니다. `env`: 소스 루트에서 `PYTHONDONTWRITEBYTECODE=1 … -p no:cacheprovider --basetemp=<밖>` 지침으로 검증합니다. 리뷰 워크트리를 열 수 없으면 조용히 낮추지 않고 exit 1. `env` 에서 리뷰어가 ignore 되지 않는 산출물을 소스 루트에 남기면 라운드가 stale 로 판정돼 재리뷰가 반복됩니다(fail-closed) — 산출물은 `--basetemp` 등으로 소스 밖에 둡니다. |
|
||||
| **Remote reviewers** | `--remote-review refuse\|diff-only` | `--worktree`/`--base` 에서만 의미. 원격 리뷰어(`transport: remote`)는 이 호스트의 리뷰 트리에 닿을 수 없으므로 기본으로 **어떤 잡도 위임하기 전에** 거부(exit 1)합니다. `diff-only` 는 스냅샷 SHA 와 diff 만을 근거로 받아들이고 그렇게 기록합니다. |
|
||||
|
||||
`--worktree` 또는 `--base` 가 있으면 매 리뷰 라운드(셀프 리뷰 포함) 전에 소스 루트의 변경분을 git 종료 코드로 판정합니다. 변경분이 비었거나 판정할 수 없으면 리뷰를 요청하지 않고 exit 1 로 끝납니다(fail-closed). 둘 다 없으면 기존 동작 그대로입니다.
|
||||
|
||||
**리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-<jid>.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다.
|
||||
**리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-<jid>.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다. 서브모듈 안의 미커밋·untracked 내용도 스냅샷에는 gitlink 커밋만 남으므로 거부합니다(라운드 시작 시 exit 1, 리뷰 중에 생기면 그 라운드는 stale). `submodule.*.ignore` 설정으로 가려지지 않습니다. 리뷰어가 없으면 creator 셀프 리뷰가 같은 리뷰어 경로(스냅샷 diff·증거 검사·승격 직전 재확인·사이드카)를 거치고, NOT PASS 면 수정 잡 없이 다시 리뷰합니다. 리뷰 잡의 `[워크트리 컨텍스트]` 는 검증 대상을 `[리뷰 identity]` 의 검증 경로 하나로만 지시합니다.
|
||||
|
||||
구현이 루프 밖(워크트리의 creator 에게 `delegate-job --type direct`)에서 끝났다면, **구현 위임 전에** base 를 기록해 두고 리뷰만 돌립니다:
|
||||
|
||||
|
||||
@@ -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