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