From d907359746524f538fe1aab30784bdad21b3207d Mon Sep 17 00:00:00 2001 From: Godopu Date: Mon, 14 Sep 2026 03:21:43 +0000 Subject: [PATCH] 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 (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 (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 Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G --- .agents/skills/lib_py/loop/runner.py | 252 ++++++++++++--- .agents/skills/multi-agent-mux-loop/SKILL.md | 17 +- .../multi-agent-mux-loop/scripts/run_loop.sh | 18 ++ tests/test_issue21_worktree_prereqs.py | 289 ++++++++++++++++++ 4 files changed, 539 insertions(+), 37 deletions(-) diff --git a/.agents/skills/lib_py/loop/runner.py b/.agents/skills/lib_py/loop/runner.py index 57b0634..4ae8434 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -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 Source worktree for diff/base/head (control plane stays here)", + " --base Pin the review diff base (must be an ancestor of the source HEAD)", + " --review-only Skip implementation; review ..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 .", 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: diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 7e7fa26..3fd113e 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -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 ` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). | +| **Diff base** | `--base ` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. | +| **Review only** | `--review-only` | Phase 2(구현 위임)를 건너뛰고 `..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 "" --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//` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다. diff --git a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh index 26999df..f83b853 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -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 Source worktree for diff/base/head (control plane stays here)" + echo " --base Pin the review diff base (must be an ancestor of the source HEAD)" + echo " --review-only Skip implementation; review ..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 diff --git a/tests/test_issue21_worktree_prereqs.py b/tests/test_issue21_worktree_prereqs.py index 563d329..e848c89 100644 --- a/tests/test_issue21_worktree_prereqs.py +++ b/tests/test_issue21_worktree_prereqs.py @@ -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