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
|
||||
|
||||
Reference in new issue
Block a user