feat(issue-21): loop runner --worktree/--base/--review-only with a fail-closed empty change-set guard; control plane stays in the main checkout
- --worktree <path> (CLI over MAM_LOOP_WORKTREE): the source root. Only the
diff, base, head and the empty-set check use it; the wrapper, registry,
checkpoint, loop guard, promotion and cleanup stay on the control root. The
collector is the control root's diff_collect.sh (never the worktree's
unreviewed copy), its arguments shlex-quoted. Validation: an existing
directory, the top of its working tree, the same git common dir as the
control root (".git" is resolved against the directory it came from).
- --base <ref> (CLI only, no env - a stale sha would silently widen the next
range): resolved to a full sha, must be an ancestor of the source HEAD.
- --review-only: requires --base, refuses --plan; Phase 2 is skipped and
logged; correction and comply jobs still go to the creator.
- With --worktree or --base every delegated prompt starts with a context
block (path, branch, base and its origin, head, base..head commit count,
uncommitted changes); without them prompts are byte-identical. All call
sites in run() go through one _dispatch helper.
- Fail-closed guard (only with --worktree/--base): before each review round,
self-review included, an empty change set or one git cannot determine
(exit codes, never output parsing) ends the loop with rc 1 instead of
asking for reviews. This closes the fail-open where a loop started after
an out-of-loop commit sent reviewers "(no changes since base commit)".
- The runner honours MAM_JOBS_DIR and passes --registry-dir to the wrapper.
- run_loop.sh accepts the three options; the legacy shell loop
(MAM_LOOP_PY=0) refuses them before the freeze and the loop lock.
Tests T2-1..T2-14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
1 parent
d3d3a76c83
commit
d907359746
4 files changed
+539
-37
No files matched your search
@@ -13,6 +13,7 @@ import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import sqlite3
|
||||
@@ -20,7 +21,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from lib_py.loop import checkpoint, eager, parser
|
||||
|
||||
@@ -58,6 +59,18 @@ class LoopRunner:
|
||||
self.workspace_root = Path(os.environ.get("MAM_REAL_ROOT") or os.environ.get("WORKSPACE_ROOT") or Path.cwd()).resolve()
|
||||
self.mam_dir = self.workspace_root / ".mam"
|
||||
self.checkpoint_file = self.mam_dir / "loop-checkpoint.json"
|
||||
# Issue #21: the jobs dir honours MAM_JOBS_DIR (and the wrapper is told
|
||||
# the same dir explicitly). The source root (--worktree) is where the
|
||||
# diff, base and head come from; everything else - wrapper, registry,
|
||||
# checkpoint, loop guard, promotion, cleanup - stays on the control
|
||||
# root (workspace_root).
|
||||
self.jobs_dir = Path(os.environ.get("MAM_JOBS_DIR") or self.mam_dir / "jobs")
|
||||
self.worktree_root: Path = self.workspace_root
|
||||
self.worktree_given = False
|
||||
self.base_ref: Optional[str] = None
|
||||
self.base_sha = ""
|
||||
self.base_source = "head"
|
||||
self.review_only = False
|
||||
self.created_jobs: List[str] = []
|
||||
self.failed_submit_jobs: Set[str] = set()
|
||||
self.current_plan = ""
|
||||
@@ -93,6 +106,9 @@ class LoopRunner:
|
||||
" --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)",
|
||||
" --verbose Print detailed execution timeline traces",
|
||||
" --cleanup Purge temporary job directories upon success",
|
||||
" --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)",
|
||||
" --review-only Skip implementation; review <base>..HEAD (requires --base)",
|
||||
]
|
||||
print("\n".join(lines))
|
||||
|
||||
@@ -161,6 +177,22 @@ class LoopRunner:
|
||||
elif arg == "--cleanup":
|
||||
self.cleanup = True
|
||||
i += 1
|
||||
elif arg == "--worktree":
|
||||
if i + 1 >= len(args):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
self.worktree_root = Path(args[i + 1])
|
||||
self.worktree_given = True
|
||||
i += 2
|
||||
elif arg == "--base":
|
||||
if i + 1 >= len(args):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
self.base_ref = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--review-only":
|
||||
self.review_only = True
|
||||
i += 1
|
||||
else:
|
||||
print(f"Unknown option: {arg}")
|
||||
self.print_usage()
|
||||
@@ -183,6 +215,67 @@ class LoopRunner:
|
||||
if self.plan_talk_turns > 0 and not self.plan_mode:
|
||||
log_warn("--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored.")
|
||||
|
||||
# Issue #21 (D3): CLI --worktree wins over MAM_LOOP_WORKTREE. --base has
|
||||
# no env fallback (D17): a stale sha left in a shell would still pass
|
||||
# the ancestor check and silently widen the next loop's review range.
|
||||
if not self.worktree_given and os.environ.get("MAM_LOOP_WORKTREE", "").strip():
|
||||
self.worktree_root = Path(os.environ["MAM_LOOP_WORKTREE"].strip())
|
||||
self.worktree_given = True
|
||||
if self.worktree_given:
|
||||
self.worktree_root = self._validate_worktree(self.worktree_root)
|
||||
if self.review_only and self.base_ref is None:
|
||||
print("ERROR: --review-only requires --base <ref>.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if self.review_only and self.plan_mode:
|
||||
print("ERROR: --review-only cannot be combined with --plan.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if self.base_ref is not None:
|
||||
self.base_sha = self._resolve_base(self.base_ref)
|
||||
self.base_source = "cli"
|
||||
|
||||
@staticmethod
|
||||
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
|
||||
|
||||
def _git_common_dir(self, cwd: Path) -> str:
|
||||
res = self._git(cwd, "rev-parse", "--git-common-dir")
|
||||
if res.returncode != 0 or not res.stdout.strip():
|
||||
return ""
|
||||
d = res.stdout.strip()
|
||||
# the main checkout answers ".git", relative to cwd (not to this process)
|
||||
return os.path.realpath(d if os.path.isabs(d) else os.path.join(str(cwd), d))
|
||||
|
||||
def _validate_worktree(self, p: Path) -> Path:
|
||||
p = Path(os.path.abspath(str(p)))
|
||||
if not p.is_dir():
|
||||
log_error(f"--worktree {p}: no such directory.")
|
||||
sys.exit(1)
|
||||
top = self._git(p, "rev-parse", "--show-toplevel")
|
||||
if top.returncode != 0 or not top.stdout.strip():
|
||||
log_error(f"--worktree {p}: not a git working tree.")
|
||||
sys.exit(1)
|
||||
if os.path.realpath(top.stdout.strip()) != os.path.realpath(p):
|
||||
log_error(f"--worktree {p}: not the top of its working tree ({top.stdout.strip()}).")
|
||||
sys.exit(1)
|
||||
mine, ctl = self._git_common_dir(p), self._git_common_dir(self.workspace_root)
|
||||
if not mine or mine != ctl:
|
||||
log_error(f"--worktree {p}: not a worktree of the repository at {self.workspace_root}.")
|
||||
sys.exit(1)
|
||||
return Path(os.path.realpath(p))
|
||||
|
||||
def _resolve_base(self, ref: str) -> str:
|
||||
"""--base (D14): a full sha that is an ancestor of the source HEAD."""
|
||||
res = self._git(self.worktree_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
||||
sha = res.stdout.strip() if res.returncode == 0 else ""
|
||||
if not sha:
|
||||
log_error(f"--base {ref}: not a commit in {self.worktree_root}.")
|
||||
sys.exit(1)
|
||||
if self._git(self.worktree_root, "merge-base", "--is-ancestor", sha, "HEAD").returncode != 0:
|
||||
log_error(f"--base {ref} ({sha[:12]}) is not an ancestor of HEAD in {self.worktree_root}; "
|
||||
"refusing to review an unrelated range.")
|
||||
sys.exit(1)
|
||||
return sha
|
||||
|
||||
def load_sessions_state(self) -> Dict[str, Any]:
|
||||
"""Load registered sessions from MAM_STATE_JSON or agent-sessions.db/yaml."""
|
||||
raw_env = os.environ.get("MAM_STATE_JSON")
|
||||
@@ -296,6 +389,7 @@ class LoopRunner:
|
||||
"--agent", agent_type,
|
||||
"--type", "direct",
|
||||
"--role", role,
|
||||
"--registry-dir", str(self.jobs_dir),
|
||||
"--prompt", p_text,
|
||||
] + self.contract_args()
|
||||
env = dict(os.environ)
|
||||
@@ -346,7 +440,7 @@ class LoopRunner:
|
||||
if self.verbose:
|
||||
log_info(f"Monitoring job '{job_id}' for status changes (timeout: {max_wait}s)...")
|
||||
|
||||
job_file = self.mam_dir / "jobs" / f"{job_id}.json"
|
||||
job_file = self.jobs_dir / f"{job_id}.json"
|
||||
start_time = time.time()
|
||||
missing_count = 0
|
||||
unknown_count = 0
|
||||
@@ -388,7 +482,7 @@ class LoopRunner:
|
||||
|
||||
def find_report(self, job_id: str) -> Optional[Path]:
|
||||
"""Locate report-final.md for a job."""
|
||||
job_dir = self.mam_dir / "jobs" / job_id
|
||||
job_dir = self.jobs_dir / job_id
|
||||
matches = list(job_dir.glob("*/report-final.md")) + list(job_dir.glob("report-final.md"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
@@ -396,20 +490,94 @@ class LoopRunner:
|
||||
|
||||
def collect_changes_diff(self) -> str:
|
||||
"""Collect git diff since base commit."""
|
||||
# The collector is the control root's copy (never the worktree's
|
||||
# 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"
|
||||
src = str(self.worktree_root)
|
||||
if diff_script.exists():
|
||||
cmd = f"source '{diff_script}' && mam_collect_changes_diff '{self.workspace_root}' '{self.base_commit}'"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(self.workspace_root))
|
||||
cmd = (f"source {shlex.quote(str(diff_script))} && "
|
||||
f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}")
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src)
|
||||
if res.returncode == 0:
|
||||
return res.stdout
|
||||
# Fallback to direct git diff
|
||||
try:
|
||||
cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root))
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=src)
|
||||
return res.stdout
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _source_head(self) -> str:
|
||||
res = self._git(self.worktree_root, "rev-parse", "HEAD")
|
||||
out = res.stdout if isinstance(res.stdout, str) else ""
|
||||
return out.strip() if res.returncode == 0 else ""
|
||||
|
||||
def _context_active(self) -> bool:
|
||||
return self.worktree_given or self.base_ref is not None
|
||||
|
||||
def _worktree_context(self) -> str:
|
||||
"""The source-root block put in front of every delegated prompt (D6);
|
||||
empty without --worktree/--base, so prompts stay byte-identical."""
|
||||
if not self._context_active():
|
||||
return ""
|
||||
src = self.worktree_root
|
||||
branch = self._git(src, "branch", "--show-current").stdout.strip() or "(detached)"
|
||||
head = self._source_head()
|
||||
base = self.base_commit or self.base_sha
|
||||
origin = "--base" if self.base_source == "cli" else "루프 시작 시 HEAD"
|
||||
count = "?"
|
||||
if base and head:
|
||||
r = self._git(src, "rev-list", "--count", f"{base}..HEAD")
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
count = r.stdout.strip()
|
||||
st = self._git(src, "status", "--porcelain")
|
||||
dirty = "알 수 없음" if st.returncode != 0 else ("있음" if st.stdout.strip() else "없음")
|
||||
return ("[워크트리 컨텍스트]\n"
|
||||
f"- 소스 경로: {src}\n"
|
||||
f"- 브랜치: {branch}\n"
|
||||
f"- base: {base or '(Phase 2 시작 시 HEAD 로 정해짐)'} (출처: {origin})\n"
|
||||
f"- head: {head} (base..head 커밋: {count}개, 미커밋 변경: {dirty})\n"
|
||||
f"- 제어 평면(잡 브리프·리포트·레지스트리): {self.workspace_root}\n"
|
||||
"이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n")
|
||||
|
||||
def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str) -> 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() + prompt)
|
||||
|
||||
def _change_set_state(self) -> str:
|
||||
"""'empty' | 'present' | 'unknown' for the source root against base,
|
||||
judged by git exit codes only (D16)."""
|
||||
if not self.base_commit:
|
||||
return "unknown"
|
||||
try:
|
||||
d = self._git(self.worktree_root, "diff", "--quiet", self.base_commit)
|
||||
if d.returncode == 1:
|
||||
return "present"
|
||||
if d.returncode != 0:
|
||||
return "unknown"
|
||||
u = self._git(self.worktree_root, "ls-files", "-o", "--exclude-standard", "-z")
|
||||
if u.returncode != 0:
|
||||
return "unknown"
|
||||
return "present" if u.stdout else "empty"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
def _change_set_allows_review(self) -> bool:
|
||||
"""Fail-closed guard (D16), on only with --worktree/--base: never ask
|
||||
for a review of an empty or undeterminable change set."""
|
||||
if not self._context_active():
|
||||
return True
|
||||
state = self._change_set_state()
|
||||
if state == "empty":
|
||||
log_error(f"empty change set since base {self.base_commit}; refusing to request reviews")
|
||||
return False
|
||||
if state != "present":
|
||||
log_error(f"cannot determine the change set since base {self.base_commit or '(none)'} in "
|
||||
f"{self.worktree_root}; refusing to request reviews on no evidence")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
|
||||
data = {
|
||||
"iteration": iteration,
|
||||
@@ -418,6 +586,10 @@ class LoopRunner:
|
||||
"plan_job_id": self.plan_job_id,
|
||||
"review_status": review_status or {},
|
||||
"base_commit": self.base_commit,
|
||||
"base_source": self.base_source,
|
||||
"review_only": self.review_only,
|
||||
"worktree_root": str(self.worktree_root),
|
||||
"head_commit": self._source_head() if self._context_active() else "",
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
checkpoint.save_checkpoint(self.checkpoint_file, data)
|
||||
@@ -506,7 +678,7 @@ class LoopRunner:
|
||||
log_info("Requesting initial implementation plan from Planner...")
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
plan_prompt = f"태스크 목표를 바탕으로 구체적인 구현 계획서를 작성해주세요. 목표: {self.task}"
|
||||
p_jid = self.delegate_job(planner_session, p_type, "Planner", plan_prompt)
|
||||
p_jid = self._dispatch(planner_session, p_type, "Planner", plan_prompt)
|
||||
if not p_jid:
|
||||
log_error("Failed to register planner job.")
|
||||
return 1
|
||||
@@ -528,7 +700,7 @@ class LoopRunner:
|
||||
for turn in range(1, self.plan_talk_turns + 1):
|
||||
log_info(f"Discussion Turn {turn}/{self.plan_talk_turns}: Creator challenging the plan...")
|
||||
c_prompt = f"Planner가 제시한 다음 계획서를 꼼꼼히 검토하고, 실제 구현 시 마주할 수 있는 맹점이나 제약사항 1가지를 발굴하여 Planner에게 이의를 제기(Challenge)해주세요. 계획서:\n{self.current_plan}"
|
||||
c_jid = self.delegate_job(self.creator, creator_type, "Worker", c_prompt)
|
||||
c_jid = self._dispatch(self.creator, creator_type, "Worker", c_prompt)
|
||||
if not c_jid or not self.wait_for_job(c_jid):
|
||||
log_error("Creator critique step failed.")
|
||||
return 1
|
||||
@@ -538,7 +710,7 @@ class LoopRunner:
|
||||
|
||||
log_info("Planner refining plan with Creator's feedback...")
|
||||
refine_prompt = f"작업자(Creator)로부터 다음 이의제기 피드백을 받았습니다. 피드백을 반영하여 계획서를 정교하게 업데이트(Refine)하여 다시 출력해주세요. 피드백:\n{critique_text}\n기존 계획서:\n{self.current_plan}"
|
||||
ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt)
|
||||
ref_jid = self._dispatch(planner_session, p_type, "Planner", refine_prompt)
|
||||
if not ref_jid or not self.wait_for_job(ref_jid):
|
||||
log_error("Planner refinement step failed.")
|
||||
return 1
|
||||
@@ -560,28 +732,34 @@ class LoopRunner:
|
||||
# PHASE 2: IMPLEMENTATION (CREATION)
|
||||
# =======================================================================
|
||||
log_info("=== Phase 2: Code Implementation ===")
|
||||
try:
|
||||
res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.workspace_root))
|
||||
self.base_commit = res_base.stdout.strip()
|
||||
except Exception:
|
||||
self.base_commit = ""
|
||||
|
||||
if self.current_plan:
|
||||
exec_prompt = f"이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n{self.current_plan}\n\n작업 목표: {self.task}"
|
||||
if self.base_sha:
|
||||
self.base_commit = self.base_sha # --base (D14)
|
||||
else:
|
||||
exec_prompt = f"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"
|
||||
try:
|
||||
res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.worktree_root))
|
||||
self.base_commit = res_base.stdout.strip()
|
||||
except Exception:
|
||||
self.base_commit = ""
|
||||
|
||||
creator_type = self.resolve_agent_type(self.creator, state)
|
||||
exec_jid = self.delegate_job(self.creator, creator_type, "Worker", exec_prompt)
|
||||
if not exec_jid:
|
||||
log_error("Failed to register Creator execution job.")
|
||||
return 1
|
||||
log_info(f"Creator Job ID: {exec_jid}")
|
||||
if self.review_only:
|
||||
log_info(f"Phase 2 skipped (--review-only): reviewing {self.base_commit}..{self._source_head()}")
|
||||
else:
|
||||
if self.current_plan:
|
||||
exec_prompt = f"이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n{self.current_plan}\n\n작업 목표: {self.task}"
|
||||
else:
|
||||
exec_prompt = f"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"
|
||||
|
||||
if not self.wait_for_job(exec_jid):
|
||||
log_error("Creator execution failed.")
|
||||
return 1
|
||||
log_success("Initial implementation finished.")
|
||||
exec_jid = self._dispatch(self.creator, creator_type, "Worker", exec_prompt)
|
||||
if not exec_jid:
|
||||
log_error("Failed to register Creator execution job.")
|
||||
return 1
|
||||
log_info(f"Creator Job ID: {exec_jid}")
|
||||
|
||||
if not self.wait_for_job(exec_jid):
|
||||
log_error("Creator execution failed.")
|
||||
return 1
|
||||
log_success("Initial implementation finished.")
|
||||
|
||||
# =======================================================================
|
||||
# PHASE 3: VERIFICATION LOOP (PEER REVIEW)
|
||||
@@ -605,12 +783,14 @@ class LoopRunner:
|
||||
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
|
||||
rebut_budget = self.max_rebut
|
||||
self._record_checkpoint(iteration=loop_count)
|
||||
if not self._change_set_allows_review():
|
||||
return 1
|
||||
|
||||
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_jid = self.delegate_job(self.creator, creator_type, "Reviewer", sr_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
|
||||
@@ -634,7 +814,7 @@ class LoopRunner:
|
||||
log_info(f"Requesting code review from Reviewer '{rev}'...")
|
||||
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_jid = self.delegate_job(rev, r_type, "Reviewer", r_prompt)
|
||||
r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt)
|
||||
if not r_jid:
|
||||
log_error(f"Failed to register review job for '{rev}'.")
|
||||
return 1
|
||||
@@ -685,7 +865,7 @@ class LoopRunner:
|
||||
log_warn("Feedback involves complex code modifications. Diverting to Planner to revise plan...")
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
refine_prompt = f"리뷰어들로부터 다음과 같이 정교한 코드 수정 피드백이 도착했습니다. 해당 피드백을 수렴하여 구현 계획서(Plan)를 갱신(Refine)하여 다시 작성해주세요. 피드백:\n{feedback_aggregate}\n기존 계획서:\n{self.current_plan}"
|
||||
ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt)
|
||||
ref_jid = self._dispatch(planner_session, p_type, "Planner", refine_prompt)
|
||||
if not ref_jid or not self.wait_for_job(ref_jid):
|
||||
log_error("Failed to register plan refinement job.")
|
||||
return 1
|
||||
@@ -700,7 +880,7 @@ class LoopRunner:
|
||||
if self.max_rebut > 0:
|
||||
correction_prompt += f"\n\n[이의제기 채널] 위 피드백 중 타당하지 않다고 판단되는 항목이 있다면, 그 항목은 구현하지 말고 반론을 제기하십시오. 반론 시 리포트에 단독 행으로 '[REBUT: <리뷰어_세션명>]' 태그를 남기고, 해당 리뷰어의 지적 중 어느 항목을 왜 거부하는지 근거를 함께 서술하십시오. 타당한 지적은 정상적으로 반영하십시오. 반론할 항목이 없으면 태그를 남기지 마십시오. 대상 리뷰어 세션명: {' '.join(fail_revs) if fail_revs else '(없음)'}"
|
||||
|
||||
correct_jid = self.delegate_job(self.creator, creator_type, "Worker", correction_prompt)
|
||||
correct_jid = self._dispatch(self.creator, creator_type, "Worker", correction_prompt)
|
||||
if not correct_jid or not self.wait_for_job(correct_jid):
|
||||
log_error("Failed to register Creator correction job.")
|
||||
return 1
|
||||
@@ -728,7 +908,7 @@ class LoopRunner:
|
||||
log_info(f"Re-adjudication: returning rebuttal to Reviewer '{rt}'...")
|
||||
rt_type = self.resolve_agent_type(rt, state)
|
||||
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
|
||||
adj_jid = self.delegate_job(rt, rt_type, "Reviewer", adj_prompt)
|
||||
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt)
|
||||
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)
|
||||
@@ -747,7 +927,7 @@ class LoopRunner:
|
||||
log_info(f"Deadlock on {' '.join(overruled_revs)}; escalating to Planner for arbitration...")
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
arb_prompt = f"작업자(Creator)와 리뷰어의 의견이 충돌하여 교착 상태입니다. 계획서 관점에서 어느 쪽이 타당한지 재정(arbitration)하십시오. 리포트 마지막에 단독 행으로 '[ARBITRATION: CREATOR]' 또는 '[ARBITRATION: REVIEWER]' 를 명시하십시오. 리뷰어 지적:\n{feedback_aggregate}\n작업자 반론:\n{rebuttal_text}\n기존 계획서:\n{self.current_plan}"
|
||||
arb_jid = self.delegate_job(planner_session, p_type, "Planner", arb_prompt)
|
||||
arb_jid = self._dispatch(planner_session, p_type, "Planner", arb_prompt)
|
||||
if arb_jid and self.wait_for_job(arb_jid):
|
||||
arb_file = self.find_report(arb_jid)
|
||||
if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"):
|
||||
@@ -761,7 +941,7 @@ class LoopRunner:
|
||||
log_warn("Ruling stands with the reviewer(s); Creator must comply.")
|
||||
rebut_budget = 0
|
||||
comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}"
|
||||
comp_jid = self.delegate_job(self.creator, creator_type, "Worker", comply_prompt)
|
||||
comp_jid = self._dispatch(self.creator, creator_type, "Worker", comply_prompt)
|
||||
if comp_jid:
|
||||
self.wait_for_job(comp_jid)
|
||||
|
||||
@@ -788,10 +968,10 @@ class LoopRunner:
|
||||
if self.cleanup:
|
||||
log_info("Cleaning up temporary job directories created during this loop run...")
|
||||
for job in self.created_jobs:
|
||||
job_dir = self.mam_dir / "jobs" / job
|
||||
job_dir = self.jobs_dir / job
|
||||
if job_dir.exists():
|
||||
shutil.rmtree(job_dir, ignore_errors=True)
|
||||
sub_out = self.mam_dir / "jobs" / f"{job}.subscriber.out"
|
||||
sub_out = self.jobs_dir / f"{job}.subscriber.out"
|
||||
if sub_out.exists():
|
||||
sub_out.unlink(missing_ok=True)
|
||||
if self.verbose:
|
||||
|
||||
@@ -195,6 +195,21 @@ sequenceDiagram
|
||||
| **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). |
|
||||
| **Rebuttal** | `--max-rebut N` | 이터레이션당 Creator 반론 횟수 (기본 1, `0`이면 끔). |
|
||||
| **Control** | `--verbose` / `--cleanup` | 상세 로그 / 성공 시 임시 job 디렉터리 삭제. |
|
||||
| **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 에게 갑니다. |
|
||||
|
||||
`--worktree` 또는 `--base` 가 있으면 매 리뷰 라운드(셀프 리뷰 포함) 전에 소스 루트의 변경분을 git 종료 코드로 판정합니다. 변경분이 비었거나 판정할 수 없으면 리뷰를 요청하지 않고 exit 1 로 끝납니다(fail-closed). 둘 다 없으면 기존 동작 그대로입니다.
|
||||
|
||||
구현이 루프 밖(워크트리의 creator 에게 `delegate-job --type direct`)에서 끝났다면, **구현 위임 전에** base 를 기록해 두고 리뷰만 돌립니다:
|
||||
|
||||
```bash
|
||||
BASE=$(git -C "$WT" rev-parse HEAD) # 구현 위임 전에 기록
|
||||
# ... creator 가 $WT 에서 구현·커밋 ...
|
||||
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||
--worktree "$WT" --base "$BASE" --review-only \
|
||||
--creator "<worktree-creator>" --reviewer "<reviewer>" --task "Issue #N review"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -232,5 +247,5 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||
- **Incorrect Verdict format (앵커링 파서 하드닝)**: 리뷰 리포트 파일 내에서 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰은 반드시 리포트의 **마지막에 단독 행**으로 기재되어야 합니다. 코드 인용이나 변경 diff 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다.
|
||||
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
|
||||
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive` 및 `running`) 있어야 합니다.
|
||||
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오.
|
||||
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오. (실제로는 loop lock 이 두 번째 루프를 exit 1 로 거부한다 — run_loop.sh:189-203)
|
||||
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
|
||||
@@ -32,6 +32,9 @@ TARGET_AGENT=""
|
||||
TASK=""
|
||||
REVIEWER_LIST=""
|
||||
PLANNER_SESSION_OVERRIDE=""
|
||||
LOOP_WORKTREE=""
|
||||
LOOP_BASE=""
|
||||
LOOP_REVIEW_ONLY=0
|
||||
|
||||
# Print usage instructions
|
||||
usage() {
|
||||
@@ -47,6 +50,9 @@ usage() {
|
||||
echo " --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)"
|
||||
echo " --verbose Print detailed execution timeline traces"
|
||||
echo " --cleanup Purge temporary job directories upon success"
|
||||
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 " --review-only Skip implementation; review <base>..HEAD (requires --base)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -83,6 +89,9 @@ while [[ "$#" -gt 0 ]]; do
|
||||
;;
|
||||
--planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;;
|
||||
--task) TASK="$2"; shift 2 ;;
|
||||
--worktree) LOOP_WORKTREE="$2"; shift 2 ;;
|
||||
--base) LOOP_BASE="$2"; shift 2 ;;
|
||||
--review-only) LOOP_REVIEW_ONLY=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
@@ -104,6 +113,15 @@ if [ -n "${PLANNER_SESSION_OVERRIDE:-}" ] && [ "$PLAN_MODE" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Issue #21 (D12): the legacy shell loop assumes a cwd-relative .mam/jobs and
|
||||
# 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).
|
||||
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|
||||
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ]; }; then
|
||||
echo "ERROR: --worktree/--base/--review-only require the Python loop (MAM_LOOP_PY=1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Anchor Python interpreter before freeze so freeze child processes find the project venv (O-4)
|
||||
if [ -z "${AGENT_PYTHON_BIN:-}" ]; then
|
||||
if [ -x "$REPO_ROOT/.venv/bin/python" ]; then
|
||||
|
||||
@@ -197,3 +197,292 @@ def test_t1_5_onboarding_publish_command_is_absolute(mam_sandbox, mock_herdr, mo
|
||||
assert f"DELEGATE_JOB_LOGS_DIR={real}/.mam/delegate_job_logs " in text, text
|
||||
assert " .agents/skills/" not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §2 item 2 — loop runner --worktree / --base / --review-only, empty-set guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=True,
|
||||
env={**os.environ, **GIT_ID}).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wt_repo(tmp_path, monkeypatch):
|
||||
"""main checkout (control root) + a linked worktree on branch feat."""
|
||||
main, wt = tmp_path / "main", tmp_path / "wt"
|
||||
main.mkdir()
|
||||
_git(main, "init", "-q")
|
||||
(main / "a.txt").write_text("a\n")
|
||||
_git(main, "add", "a.txt")
|
||||
_git(main, "commit", "-q", "-m", "init")
|
||||
_git(main, "worktree", "add", "-q", "-b", "feat", str(wt))
|
||||
for k, v in GIT_ID.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
|
||||
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
|
||||
monkeypatch.setenv("MAM_JOBS_DIR", str(main / ".mam" / "jobs"))
|
||||
monkeypatch.delenv("MAM_LOOP_WORKTREE", raising=False)
|
||||
monkeypatch.delenv("MAM_LOOP_BASE", raising=False)
|
||||
monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": [
|
||||
{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"},
|
||||
{"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"},
|
||||
]}))
|
||||
return main, wt
|
||||
|
||||
|
||||
def _runner(*extra):
|
||||
from lib_py.loop import runner as lr
|
||||
return lr.LoopRunner(["--creator", "creator-claude-01", "--task", "t21", *extra])
|
||||
|
||||
|
||||
def _drive(monkeypatch, tmp_path, argv, on_worker=None):
|
||||
"""run() end to end with the delegation stubbed: every job 'completes' and
|
||||
every report is a PASS. Returns (rc, [(role, prompt)], runner)."""
|
||||
from lib_py.loop import runner as lr
|
||||
monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None)
|
||||
r = lr.LoopRunner(["--creator", "creator-claude-01", "--task", "t21",
|
||||
"--reviewer", "reviewer-claude-01", "--max-loop", "1", *argv])
|
||||
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((role, prompt))
|
||||
if role == "Worker" and on_worker:
|
||||
on_worker(r)
|
||||
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 _roles(calls, role):
|
||||
return [p for (ro, p) in calls if ro == role]
|
||||
|
||||
|
||||
def test_t2_1_diff_and_head_come_from_the_worktree(wt_repo):
|
||||
main, wt = wt_repo
|
||||
(wt / "a.txt").write_text("a\nworktree change\n")
|
||||
(main / "b.txt").write_text("main-only change\n")
|
||||
r = _runner("--worktree", str(wt))
|
||||
r.parse_args()
|
||||
r.base_commit = r._source_head()
|
||||
assert r._source_head() == _git(wt, "rev-parse", "HEAD")
|
||||
diff = r.collect_changes_diff()
|
||||
assert "worktree change" in diff
|
||||
assert "main-only change" not in diff and "b.txt" not in diff
|
||||
|
||||
|
||||
def test_t2_2_invalid_worktrees_are_refused(wt_repo, tmp_path):
|
||||
main, wt = wt_repo
|
||||
(wt / "sub").mkdir()
|
||||
(tmp_path / "plain").mkdir()
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
_git(other, "init", "-q")
|
||||
for bad in (tmp_path / "missing", tmp_path / "plain", wt / "sub", other):
|
||||
with pytest.raises(SystemExit) as e:
|
||||
_runner("--worktree", str(bad)).parse_args()
|
||||
assert e.value.code == 1, bad
|
||||
|
||||
|
||||
def test_t2_3_delegation_stays_on_the_control_root(wt_repo, monkeypatch):
|
||||
main, wt = wt_repo
|
||||
from lib_py.loop import runner as lr
|
||||
monkeypatch.setenv("MAM_HEARTBEAT_SEC", "20")
|
||||
monkeypatch.setenv("MAM_HEARTBEAT_MISSES", "3")
|
||||
monkeypatch.setenv("MAM_ACK_TIMEOUT_S", "45")
|
||||
r = _runner("--worktree", str(wt))
|
||||
r.parse_args()
|
||||
seen = []
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
seen.append((cmd, kw))
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = "registered job: cafe21\n"
|
||||
stderr = ""
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(lr.subprocess, "run", fake_run)
|
||||
assert r.delegate_job("creator-claude-01", "claude", "Worker", "go") == "cafe21"
|
||||
cmd, kw = seen[0]
|
||||
assert os.path.realpath(kw["cwd"]) == os.path.realpath(main)
|
||||
assert cmd[1].endswith("multi-agent-mux-delegate-job/multi-agent-mux-delegate-job")
|
||||
assert not cmd[1].startswith(os.path.realpath(wt))
|
||||
assert cmd[cmd.index("--registry-dir") + 1] == str(main / ".mam" / "jobs")
|
||||
assert cmd[-6:] == ["--heartbeat", "20", "--heartbeat-misses", "3", "--ack-timeout", "45"]
|
||||
|
||||
|
||||
def test_t2_4_checkpoint_lives_in_the_control_root(wt_repo):
|
||||
main, wt = wt_repo
|
||||
r = _runner("--worktree", str(wt))
|
||||
r.parse_args()
|
||||
r._record_checkpoint()
|
||||
data = json.loads((main / ".mam" / "loop-checkpoint.json").read_text())
|
||||
assert data["worktree_root"] == os.path.realpath(wt)
|
||||
assert data["base_source"] == "head" and data["review_only"] is False
|
||||
assert data["head_commit"] == _git(wt, "rev-parse", "HEAD")
|
||||
assert not (wt / ".mam").exists()
|
||||
|
||||
|
||||
def test_t2_5_prompts_carry_the_worktree_context(wt_repo, monkeypatch, tmp_path):
|
||||
main, wt = wt_repo
|
||||
head0 = _git(wt, "rev-parse", "HEAD")
|
||||
|
||||
def implement(_r):
|
||||
(wt / "a.txt").write_text("a\nimplemented in wt\n")
|
||||
|
||||
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)], on_worker=implement)
|
||||
assert rc == 0
|
||||
worker, review = _roles(calls, "Worker")[0], _roles(calls, "Reviewer")[0]
|
||||
for p in (worker, review):
|
||||
assert p.startswith("[워크트리 컨텍스트]\n")
|
||||
assert f"- 소스 경로: {os.path.realpath(wt)}\n" in p
|
||||
assert f"- base: {head0} (출처: 루프 시작 시 HEAD)" in p
|
||||
assert f"- head: {head0} (base..head 커밋: 0개" in p
|
||||
assert "미커밋 변경: 있음" in review and "implemented in wt" in review
|
||||
|
||||
|
||||
def test_t2_5b_prompts_unchanged_without_options(wt_repo, monkeypatch, tmp_path):
|
||||
main, _ = wt_repo
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
|
||||
|
||||
def implement(_r):
|
||||
(main / "a.txt").write_text("a\nimplemented in main\n")
|
||||
|
||||
rc, calls, _ = _drive(monkeypatch, tmp_path, [], on_worker=implement)
|
||||
assert rc == 0
|
||||
assert all("[워크트리 컨텍스트]" not in p for _, p in calls)
|
||||
assert _roles(calls, "Worker")[0].startswith("계획서가 존재하지 않으므로")
|
||||
assert _roles(calls, "Reviewer")[0].startswith("다음 구현 사항(작업 목표: t21)")
|
||||
|
||||
|
||||
def test_t2_6_worktree_env_fallback_and_no_base_env(wt_repo, tmp_path):
|
||||
main, wt = wt_repo
|
||||
wt2 = tmp_path / "wt2"
|
||||
_git(main, "worktree", "add", "-q", "-b", "feat2", str(wt2))
|
||||
os.environ["MAM_LOOP_WORKTREE"] = str(wt)
|
||||
os.environ["MAM_LOOP_BASE"] = _git(main, "rev-parse", "HEAD")
|
||||
try:
|
||||
r = _runner()
|
||||
r.parse_args()
|
||||
assert r.worktree_root == Path(os.path.realpath(wt)) and r.worktree_given
|
||||
assert r.base_ref is None and r.base_sha == "" and r.base_source == "head", "MAM_LOOP_BASE is ignored (D17)"
|
||||
r = _runner("--worktree", str(wt2))
|
||||
r.parse_args()
|
||||
assert r.worktree_root == Path(os.path.realpath(wt2)), "the CLI wins over MAM_LOOP_WORKTREE"
|
||||
finally:
|
||||
os.environ.pop("MAM_LOOP_WORKTREE", None)
|
||||
os.environ.pop("MAM_LOOP_BASE", None)
|
||||
|
||||
|
||||
def test_t2_7_runner_reads_mam_jobs_dir(wt_repo, tmp_path, monkeypatch):
|
||||
x = tmp_path / "elsewhere" / "jobs"
|
||||
(x / "jid21" / "rev-reports").mkdir(parents=True)
|
||||
(x / "jid21.json").write_text(json.dumps({"status": "completed"}))
|
||||
(x / "jid21" / "rev-reports" / "report-final.md").write_text("r\n")
|
||||
monkeypatch.setenv("MAM_JOBS_DIR", str(x))
|
||||
r = _runner()
|
||||
r.parse_args()
|
||||
assert r.wait_for_job("jid21", max_wait=2) is True
|
||||
assert r.find_report("jid21") == x / "jid21" / "rev-reports" / "report-final.md"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("opts,env", [
|
||||
(["--worktree", "/nowhere"], {}),
|
||||
(["--base", "HEAD"], {}),
|
||||
(["--review-only"], {}),
|
||||
([], {"MAM_LOOP_WORKTREE": "/nowhere"}),
|
||||
])
|
||||
def test_t2_8_legacy_shell_loop_refuses_the_new_options(tmp_path, opts, env):
|
||||
marker = tmp_path / "loop-marker"
|
||||
run_env = {**os.environ, "MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1",
|
||||
"MAM_LOOP_MARKER": str(marker), **env}
|
||||
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", "c", "--task", "t", *opts],
|
||||
capture_output=True, text=True, env=run_env, cwd=str(tmp_path), timeout=60)
|
||||
assert res.returncode == 1
|
||||
assert "require the Python loop (MAM_LOOP_PY=1)" in res.stderr
|
||||
assert not marker.exists(), "refused before the loop lock"
|
||||
|
||||
|
||||
def _commit_impl(wt: Path) -> str:
|
||||
"""Implementation committed in the worktree outside the loop; returns 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 test_t2_9a_committed_impl_without_base_is_refused(wt_repo, monkeypatch, tmp_path, capsys):
|
||||
main, wt = wt_repo
|
||||
_commit_impl(wt)
|
||||
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)])
|
||||
assert rc == 1
|
||||
assert _roles(calls, "Reviewer") == []
|
||||
assert "empty change set since base" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_t2_9b_review_only_reviews_the_committed_range(wt_repo, monkeypatch, tmp_path):
|
||||
main, wt = wt_repo
|
||||
before = _commit_impl(wt)
|
||||
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"])
|
||||
assert rc == 0
|
||||
assert _roles(calls, "Worker") == [], "Phase 2 is skipped"
|
||||
review = _roles(calls, "Reviewer")[0]
|
||||
assert "+impl line" in review
|
||||
assert f"- base: {before} (출처: --base)" in review
|
||||
assert "(base..head 커밋: 1개, 미커밋 변경: 없음)" in review
|
||||
|
||||
|
||||
def test_t2_10_base_must_be_an_ancestor(wt_repo):
|
||||
main, wt = wt_repo
|
||||
_git(main, "commit", "-q", "--allow-empty", "-m", "side") # on main's branch, not in feat
|
||||
side = _git(main, "rev-parse", "HEAD")
|
||||
for ref in (side, "no-such-ref"):
|
||||
with pytest.raises(SystemExit) as e:
|
||||
_runner("--worktree", str(wt), "--base", ref).parse_args()
|
||||
assert e.value.code == 1, ref
|
||||
|
||||
|
||||
def test_t2_11_review_only_needs_base_and_no_plan(wt_repo):
|
||||
main, wt = wt_repo
|
||||
head = _git(wt, "rev-parse", "HEAD")
|
||||
for extra in (["--review-only"], ["--review-only", "--base", head, "--plan"]):
|
||||
with pytest.raises(SystemExit) as e:
|
||||
_runner("--worktree", str(wt), *extra).parse_args()
|
||||
assert e.value.code == 1, extra
|
||||
|
||||
|
||||
def test_t2_12_guard_is_off_without_options(wt_repo, monkeypatch, tmp_path):
|
||||
"""Compatibility: no --worktree/--base, nothing changed - the review is
|
||||
still requested exactly as before."""
|
||||
rc, calls, _ = _drive(monkeypatch, tmp_path, [])
|
||||
assert rc == 0
|
||||
assert len(_roles(calls, "Reviewer")) == 1
|
||||
|
||||
|
||||
def test_t2_13_base_without_review_only_still_implements(wt_repo, monkeypatch, tmp_path):
|
||||
main, wt = wt_repo
|
||||
before = _commit_impl(wt)
|
||||
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before])
|
||||
assert rc == 0
|
||||
assert len(_roles(calls, "Worker")) == 1
|
||||
assert "+impl line" in _roles(calls, "Reviewer")[0]
|
||||
|
||||
|
||||
def test_t2_14_undeterminable_change_set_is_refused(wt_repo, monkeypatch, tmp_path, capsys):
|
||||
main, wt = wt_repo
|
||||
|
||||
def corrupt(r):
|
||||
r.base_commit = "0" * 40 # not an object: git diff exits 128
|
||||
|
||||
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)], on_worker=corrupt)
|
||||
assert rc == 1
|
||||
assert _roles(calls, "Reviewer") == []
|
||||
assert "refusing to request reviews on no evidence" in capsys.readouterr().err
|
||||
Reference in new issue
Block a user