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:
GodopuandClaude Opus 5 committed 2026-09-14 03:21:43 +00:00
1 parent d3d3a76c83
commit d907359746
4 files changed
+539 -37

No files matched your search

+216 -36
View File
@@ -13,6 +13,7 @@ import glob
import json import json
import os import os
import re import re
import shlex
import shutil import shutil
import signal import signal
import sqlite3 import sqlite3
@@ -20,7 +21,7 @@ import subprocess
import sys import sys
import time import time
from pathlib import Path 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 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.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.mam_dir = self.workspace_root / ".mam"
self.checkpoint_file = self.mam_dir / "loop-checkpoint.json" 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.created_jobs: List[str] = []
self.failed_submit_jobs: Set[str] = set() self.failed_submit_jobs: Set[str] = set()
self.current_plan = "" self.current_plan = ""
@@ -93,6 +106,9 @@ class LoopRunner:
" --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)", " --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)",
" --verbose Print detailed execution timeline traces", " --verbose Print detailed execution timeline traces",
" --cleanup Purge temporary job directories upon success", " --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)) print("\n".join(lines))
@@ -161,6 +177,22 @@ class LoopRunner:
elif arg == "--cleanup": elif arg == "--cleanup":
self.cleanup = True self.cleanup = True
i += 1 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: else:
print(f"Unknown option: {arg}") print(f"Unknown option: {arg}")
self.print_usage() self.print_usage()
@@ -183,6 +215,67 @@ class LoopRunner:
if self.plan_talk_turns > 0 and not self.plan_mode: 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.") 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]: def load_sessions_state(self) -> Dict[str, Any]:
"""Load registered sessions from MAM_STATE_JSON or agent-sessions.db/yaml.""" """Load registered sessions from MAM_STATE_JSON or agent-sessions.db/yaml."""
raw_env = os.environ.get("MAM_STATE_JSON") raw_env = os.environ.get("MAM_STATE_JSON")
@@ -296,6 +389,7 @@ class LoopRunner:
"--agent", agent_type, "--agent", agent_type,
"--type", "direct", "--type", "direct",
"--role", role, "--role", role,
"--registry-dir", str(self.jobs_dir),
"--prompt", p_text, "--prompt", p_text,
] + self.contract_args() ] + self.contract_args()
env = dict(os.environ) env = dict(os.environ)
@@ -346,7 +440,7 @@ class LoopRunner:
if self.verbose: if self.verbose:
log_info(f"Monitoring job '{job_id}' for status changes (timeout: {max_wait}s)...") 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() start_time = time.time()
missing_count = 0 missing_count = 0
unknown_count = 0 unknown_count = 0
@@ -388,7 +482,7 @@ class LoopRunner:
def find_report(self, job_id: str) -> Optional[Path]: def find_report(self, job_id: str) -> Optional[Path]:
"""Locate report-final.md for a job.""" """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")) matches = list(job_dir.glob("*/report-final.md")) + list(job_dir.glob("report-final.md"))
if matches: if matches:
return matches[0] return matches[0]
@@ -396,20 +490,94 @@ class LoopRunner:
def collect_changes_diff(self) -> str: def collect_changes_diff(self) -> str:
"""Collect git diff since base commit.""" """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" diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
src = str(self.worktree_root)
if diff_script.exists(): if diff_script.exists():
cmd = f"source '{diff_script}' && mam_collect_changes_diff '{self.workspace_root}' '{self.base_commit}'" cmd = (f"source {shlex.quote(str(diff_script))} && "
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(self.workspace_root)) 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: 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"] 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 return res.stdout
except Exception: except Exception:
return "" 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: def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
data = { data = {
"iteration": iteration, "iteration": iteration,
@@ -418,6 +586,10 @@ class LoopRunner:
"plan_job_id": self.plan_job_id, "plan_job_id": self.plan_job_id,
"review_status": review_status or {}, "review_status": review_status or {},
"base_commit": self.base_commit, "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()), "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
} }
checkpoint.save_checkpoint(self.checkpoint_file, data) checkpoint.save_checkpoint(self.checkpoint_file, data)
@@ -506,7 +678,7 @@ class LoopRunner:
log_info("Requesting initial implementation plan from Planner...") log_info("Requesting initial implementation plan from Planner...")
p_type = self.resolve_agent_type(planner_session, state) p_type = self.resolve_agent_type(planner_session, state)
plan_prompt = f"태스크 목표를 바탕으로 구체적인 구현 계획서를 작성해주세요. 목표: {self.task}" 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: if not p_jid:
log_error("Failed to register planner job.") log_error("Failed to register planner job.")
return 1 return 1
@@ -528,7 +700,7 @@ class LoopRunner:
for turn in range(1, self.plan_talk_turns + 1): for turn in range(1, self.plan_talk_turns + 1):
log_info(f"Discussion Turn {turn}/{self.plan_talk_turns}: Creator challenging the plan...") 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_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): if not c_jid or not self.wait_for_job(c_jid):
log_error("Creator critique step failed.") log_error("Creator critique step failed.")
return 1 return 1
@@ -538,7 +710,7 @@ class LoopRunner:
log_info("Planner refining plan with Creator's feedback...") log_info("Planner refining plan with Creator's feedback...")
refine_prompt = f"작업자(Creator)로부터 다음 이의제기 피드백을 받았습니다. 피드백을 반영하여 계획서를 정교하게 업데이트(Refine)하여 다시 출력해주세요. 피드백:\n{critique_text}\n기존 계획서:\n{self.current_plan}" 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): if not ref_jid or not self.wait_for_job(ref_jid):
log_error("Planner refinement step failed.") log_error("Planner refinement step failed.")
return 1 return 1
@@ -560,28 +732,34 @@ class LoopRunner:
# PHASE 2: IMPLEMENTATION (CREATION) # PHASE 2: IMPLEMENTATION (CREATION)
# ======================================================================= # =======================================================================
log_info("=== Phase 2: Code Implementation ===") log_info("=== Phase 2: Code Implementation ===")
try: if self.base_sha:
res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.workspace_root)) self.base_commit = self.base_sha # --base (D14)
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}"
else: 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) creator_type = self.resolve_agent_type(self.creator, state)
exec_jid = self.delegate_job(self.creator, creator_type, "Worker", exec_prompt) if self.review_only:
if not exec_jid: log_info(f"Phase 2 skipped (--review-only): reviewing {self.base_commit}..{self._source_head()}")
log_error("Failed to register Creator execution job.") else:
return 1 if self.current_plan:
log_info(f"Creator Job ID: {exec_jid}") 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): exec_jid = self._dispatch(self.creator, creator_type, "Worker", exec_prompt)
log_error("Creator execution failed.") if not exec_jid:
return 1 log_error("Failed to register Creator execution job.")
log_success("Initial implementation finished.") 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) # PHASE 3: VERIFICATION LOOP (PEER REVIEW)
@@ -605,12 +783,14 @@ class LoopRunner:
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...") log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
rebut_budget = self.max_rebut rebut_budget = self.max_rebut
self._record_checkpoint(iteration=loop_count) self._record_checkpoint(iteration=loop_count)
if not self._change_set_allows_review():
return 1
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.delegate_job(self.creator, creator_type, "Reviewer", sr_prompt) sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt)
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
@@ -634,7 +814,7 @@ class LoopRunner:
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.delegate_job(rev, r_type, "Reviewer", r_prompt) r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt)
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
@@ -685,7 +865,7 @@ class LoopRunner:
log_warn("Feedback involves complex code modifications. Diverting to Planner to revise plan...") log_warn("Feedback involves complex code modifications. Diverting to Planner to revise plan...")
p_type = self.resolve_agent_type(planner_session, state) p_type = self.resolve_agent_type(planner_session, state)
refine_prompt = f"리뷰어들로부터 다음과 같이 정교한 코드 수정 피드백이 도착했습니다. 해당 피드백을 수렴하여 구현 계획서(Plan)를 갱신(Refine)하여 다시 작성해주세요. 피드백:\n{feedback_aggregate}\n기존 계획서:\n{self.current_plan}" 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): if not ref_jid or not self.wait_for_job(ref_jid):
log_error("Failed to register plan refinement job.") log_error("Failed to register plan refinement job.")
return 1 return 1
@@ -700,7 +880,7 @@ class LoopRunner:
if self.max_rebut > 0: if self.max_rebut > 0:
correction_prompt += f"\n\n[이의제기 채널] 위 피드백 중 타당하지 않다고 판단되는 항목이 있다면, 그 항목은 구현하지 말고 반론을 제기하십시오. 반론 시 리포트에 단독 행으로 '[REBUT: <리뷰어_세션명>]' 태그를 남기고, 해당 리뷰어의 지적 중 어느 항목을 왜 거부하는지 근거를 함께 서술하십시오. 타당한 지적은 정상적으로 반영하십시오. 반론할 항목이 없으면 태그를 남기지 마십시오. 대상 리뷰어 세션명: {' '.join(fail_revs) if fail_revs else '(없음)'}" 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): if not correct_jid or not self.wait_for_job(correct_jid):
log_error("Failed to register Creator correction job.") log_error("Failed to register Creator correction job.")
return 1 return 1
@@ -728,7 +908,7 @@ 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.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): 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)
@@ -747,7 +927,7 @@ class LoopRunner:
log_info(f"Deadlock on {' '.join(overruled_revs)}; escalating to Planner for arbitration...") log_info(f"Deadlock on {' '.join(overruled_revs)}; escalating to Planner for arbitration...")
p_type = self.resolve_agent_type(planner_session, state) 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_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): if arb_jid and self.wait_for_job(arb_jid):
arb_file = self.find_report(arb_jid) arb_file = self.find_report(arb_jid)
if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"): 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.") log_warn("Ruling stands with the reviewer(s); Creator must comply.")
rebut_budget = 0 rebut_budget = 0
comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}" 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: if comp_jid:
self.wait_for_job(comp_jid) self.wait_for_job(comp_jid)
@@ -788,10 +968,10 @@ class LoopRunner:
if self.cleanup: if self.cleanup:
log_info("Cleaning up temporary job directories created during this loop run...") log_info("Cleaning up temporary job directories created during this loop run...")
for job in self.created_jobs: for job in self.created_jobs:
job_dir = self.mam_dir / "jobs" / job job_dir = self.jobs_dir / job
if job_dir.exists(): if job_dir.exists():
shutil.rmtree(job_dir, ignore_errors=True) 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(): if sub_out.exists():
sub_out.unlink(missing_ok=True) sub_out.unlink(missing_ok=True)
if self.verbose: if self.verbose:
+16 -1
View File
@@ -195,6 +195,21 @@ sequenceDiagram
| **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). | | **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). |
| **Rebuttal** | `--max-rebut N` | 이터레이션당 Creator 반론 횟수 (기본 1, `0`이면 끔). | | **Rebuttal** | `--max-rebut N` | 이터레이션당 Creator 반론 횟수 (기본 1, `0`이면 끔). |
| **Control** | `--verbose` / `--cleanup` | 상세 로그 / 성공 시 임시 job 디렉터리 삭제. | | **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 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다. - **Incorrect Verdict format (앵커링 파서 하드닝)**: 리뷰 리포트 파일 내에서 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰은 반드시 리포트의 **마지막에 단독 행**으로 기재되어야 합니다. 코드 인용이나 변경 diff 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다.
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다. - **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive` 및 `running`) 있어야 합니다. - **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 추적성을 확보해야 합니다. - **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
@@ -32,6 +32,9 @@ TARGET_AGENT=""
TASK="" TASK=""
REVIEWER_LIST="" REVIEWER_LIST=""
PLANNER_SESSION_OVERRIDE="" PLANNER_SESSION_OVERRIDE=""
LOOP_WORKTREE=""
LOOP_BASE=""
LOOP_REVIEW_ONLY=0
# Print usage instructions # Print usage instructions
usage() { usage() {
@@ -47,6 +50,9 @@ usage() {
echo " --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)" echo " --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)"
echo " --verbose Print detailed execution timeline traces" echo " --verbose Print detailed execution timeline traces"
echo " --cleanup Purge temporary job directories upon success" 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 exit 1
} }
@@ -83,6 +89,9 @@ while [[ "$#" -gt 0 ]]; do
;; ;;
--planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;; --planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;;
--task) TASK="$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 ;; -h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;; *) echo "Unknown option: $1"; usage ;;
esac esac
@@ -104,6 +113,15 @@ if [ -n "${PLANNER_SESSION_OVERRIDE:-}" ] && [ "$PLAN_MODE" = false ]; then
exit 1 exit 1
fi 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) # Anchor Python interpreter before freeze so freeze child processes find the project venv (O-4)
if [ -z "${AGENT_PYTHON_BIN:-}" ]; then if [ -z "${AGENT_PYTHON_BIN:-}" ]; then
if [ -x "$REPO_ROOT/.venv/bin/python" ]; then if [ -x "$REPO_ROOT/.venv/bin/python" ]; then
+289
View File
@@ -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 f"DELEGATE_JOB_LOGS_DIR={real}/.mam/delegate_job_logs " in text, text
assert " .agents/skills/" not in 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