- Implement eager module loading (lib_py.loop.eager) to preload all framework dependencies into sys.modules and enforce PYTHONDONTWRITEBYTECODE=1. - Synchronize runtime protocol version with MAM_VERSION constant (4.3.0). - Bypass /tmp/mam-loop-freeze.XXXXXX copy overhead by default in Python mode while preserving legacy rollback fallback (MAM_LOOP_PY=0 / MAM_LOOP_FREEZE=1). - Add tests/test_issue5_phase4_runtime.py verifying eager import completeness, mid-flight code modification immunity (B-13), and freeze bypass. - Pass full test suite (749 tests, 0 failures).
747 lines
36 KiB
Python
747 lines
36 KiB
Python
"""Autonomous Planning, Execution, and Peer-Review Orchestrator.
|
|
|
|
Issue #5 Phase 3: Python port of run_loop.sh into lib_py.loop.runner.
|
|
Preserves public CLI contracts, exit codes, prompt shapes, and state transitions
|
|
while using fence-aware strict verdict parsing and atomic checkpointing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import atexit
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from lib_py.loop import checkpoint, eager, parser
|
|
|
|
# ANSI colors matching run_loop.sh
|
|
_COLOR_INFO = "\033[1;34m[*]\033[0m"
|
|
_COLOR_SUCCESS = "\033[1;32m[✓]\033[0m"
|
|
_COLOR_WARN = "\033[1;33m[!]\033[0m"
|
|
_COLOR_ERROR = "\033[1;31m[✗]\033[0m"
|
|
|
|
|
|
def log_info(msg: str) -> None:
|
|
print(f"{_COLOR_INFO} {msg}")
|
|
|
|
|
|
def log_success(msg: str) -> None:
|
|
print(f"{_COLOR_SUCCESS} {msg}")
|
|
|
|
|
|
def log_warn(msg: str) -> None:
|
|
print(f"{_COLOR_WARN} {msg}")
|
|
|
|
|
|
def log_error(msg: str) -> None:
|
|
print(f"{_COLOR_ERROR} {msg}", file=sys.stderr)
|
|
|
|
|
|
class LoopRunner:
|
|
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
|
|
|
def __init__(self, argv: Optional[List[str]] = None):
|
|
# Eagerly load all required framework modules to ensure immutable runtime (Phase 4)
|
|
eager.eager_load()
|
|
|
|
self.raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
|
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"
|
|
self.created_jobs: List[str] = []
|
|
self.current_plan = ""
|
|
self.plan_job_id: Optional[str] = None
|
|
self.base_commit = ""
|
|
self.interrupted = False
|
|
|
|
# Options populated by parse_args
|
|
self.creator: str = ""
|
|
self.task: str = ""
|
|
self.plan_mode: bool = False
|
|
self.planner_override: Optional[str] = None
|
|
self.plan_talk_turns: int = 1
|
|
self.reviewer_list: Optional[str] = None
|
|
self.all_reviewers: bool = False
|
|
self.max_loop: int = 3
|
|
self.max_rebut: int = 1
|
|
self.verbose: bool = False
|
|
self.cleanup: bool = False
|
|
|
|
def print_usage(self) -> None:
|
|
prog = "run_loop.sh"
|
|
lines = [
|
|
f"Usage: {prog} [options] --creator <agent-session-name> --task <goal-text>",
|
|
"Options:",
|
|
" --creator <name> Creator session that implements the task (required)",
|
|
" --plan Enable Planner agent intervention & design phase",
|
|
" --planner <name> Planner session (requires --plan; default: auto-resolve)",
|
|
" --plan-talk N Planner-Creator discussion limit turns (default: 1)",
|
|
" --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)",
|
|
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
|
|
" --max-loop N Max execution-review corrective loop runs (default: 3)",
|
|
" --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",
|
|
]
|
|
print("\n".join(lines))
|
|
|
|
def parse_args(self) -> None:
|
|
args = list(self.raw_argv)
|
|
i = 0
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg in ("-h", "--help"):
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
elif arg == "--creator":
|
|
if i + 1 >= len(args):
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
self.creator = args[i + 1]
|
|
i += 2
|
|
elif arg == "--target-agent":
|
|
print("ERROR: --target-agent was removed. Use --creator <session> instead.", file=sys.stderr)
|
|
sys.exit(1)
|
|
elif arg == "--task":
|
|
if i + 1 >= len(args):
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
self.task = args[i + 1]
|
|
i += 2
|
|
elif arg == "--plan":
|
|
self.plan_mode = True
|
|
i += 1
|
|
elif arg == "--planner":
|
|
if i + 1 >= len(args):
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
self.planner_override = args[i + 1]
|
|
i += 2
|
|
elif arg == "--plan-talk":
|
|
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
|
print("ERROR: --plan-talk requires a positive integer.", file=sys.stderr)
|
|
sys.exit(1)
|
|
self.plan_talk_turns = int(args[i + 1])
|
|
i += 2
|
|
elif arg == "--reviewer":
|
|
if i + 1 >= len(args):
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
self.reviewer_list = args[i + 1]
|
|
i += 2
|
|
elif arg == "--all-reviewer":
|
|
self.all_reviewers = True
|
|
i += 1
|
|
elif arg == "--max-loop":
|
|
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]) or int(args[i + 1]) <= 0:
|
|
print("ERROR: --max-loop requires a positive non-zero integer.", file=sys.stderr)
|
|
sys.exit(1)
|
|
self.max_loop = int(args[i + 1])
|
|
i += 2
|
|
elif arg == "--max-rebut":
|
|
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
|
print("ERROR: --max-rebut requires a non-negative integer.", file=sys.stderr)
|
|
sys.exit(1)
|
|
self.max_rebut = int(args[i + 1])
|
|
i += 2
|
|
elif arg == "--verbose":
|
|
self.verbose = True
|
|
i += 1
|
|
elif arg == "--cleanup":
|
|
self.cleanup = True
|
|
i += 1
|
|
else:
|
|
print(f"Unknown option: {arg}")
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
|
|
if not self.creator or not self.task:
|
|
print("ERROR: --creator and --task are mandatory fields.", file=sys.stderr)
|
|
self.print_usage()
|
|
sys.exit(1)
|
|
|
|
if self.planner_override and not self.plan_mode:
|
|
print("ERROR: --planner was specified without --plan.", file=sys.stderr)
|
|
print("To enable planning, include the --plan flag:", file=sys.stderr)
|
|
print(f' run_loop.sh --creator <creator> --plan --planner <planner> --task "..."', file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if self.all_reviewers and self.reviewer_list:
|
|
log_warn(f"--all-reviewer takes precedence; ignoring --reviewer list ('{self.reviewer_list}').")
|
|
|
|
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.")
|
|
|
|
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")
|
|
if raw_env:
|
|
try:
|
|
return json.loads(raw_env)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
yaml_path = os.environ.get("AGENT_SESSIONS_YAML")
|
|
if not yaml_path:
|
|
yaml_path = str(self.mam_dir / "agent-sessions.yaml")
|
|
|
|
db_path = str(Path(yaml_path).with_suffix(".db"))
|
|
d: Dict[str, Any] = {}
|
|
if os.path.exists(db_path):
|
|
try:
|
|
conn = sqlite3.connect(db_path, timeout=10.0)
|
|
cursor = conn.execute("SELECT data FROM sessions")
|
|
sessions = [json.loads(row[0]) for row in cursor.fetchall()]
|
|
d["herdr_sessions"] = sessions
|
|
conn.close()
|
|
return d
|
|
except Exception:
|
|
pass
|
|
|
|
if os.path.exists(yaml_path):
|
|
try:
|
|
import yaml
|
|
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
return yaml.safe_load(f) or {}
|
|
except Exception:
|
|
pass
|
|
|
|
return {}
|
|
|
|
def resolve_agent_type(self, name: str, state: Dict[str, Any]) -> str:
|
|
"""Resolve agent type (claude, hermes, agy, grok, opencode, codex)."""
|
|
for s in state.get("herdr_sessions", []):
|
|
if s.get("name") == name:
|
|
agent = s.get("agent") or (s.get("pane") or {}).get("cmd")
|
|
if agent:
|
|
return str(agent)
|
|
break
|
|
|
|
segments = name.split("-")
|
|
if "agy" in segments:
|
|
return "agy"
|
|
elif "hermes" in segments:
|
|
return "hermes"
|
|
elif "grok" in segments:
|
|
return "grok"
|
|
elif "opencode" in segments:
|
|
return "opencode"
|
|
elif "codex" in segments:
|
|
return "codex"
|
|
return "claude"
|
|
|
|
def resolve_all_reviewers(self, state: Dict[str, Any]) -> List[str]:
|
|
"""Resolve running reviewer sessions excluding creator."""
|
|
reviewers = []
|
|
for s in state.get("herdr_sessions", []):
|
|
role = (s.get("role") or "").lower()
|
|
if "reviewer" in role and s.get("status") == "running" and s.get("name") != self.creator:
|
|
reviewers.append(str(s.get("name")))
|
|
return reviewers
|
|
|
|
def resolve_planner_session(self, state: Dict[str, Any]) -> str:
|
|
for s in state.get("herdr_sessions", []):
|
|
role = (s.get("role") or "").lower()
|
|
if "planner" in role and s.get("status") == "running":
|
|
return str(s.get("name"))
|
|
return ""
|
|
|
|
def delegate_job(self, agent_session: str, agent_type: str, role: str, prompt: str) -> Optional[str]:
|
|
"""Submit a delegate job and extract the 32-bit hex Job ID."""
|
|
wrapper_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
|
if not wrapper_script.exists():
|
|
wrapper_script = Path(__file__).resolve().parent.parent / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
|
|
|
cmd = [
|
|
"bash", str(wrapper_script), "submit",
|
|
"--agent-session", f"herdr:{agent_session}",
|
|
"--agent", agent_type,
|
|
"--type", "direct",
|
|
"--role", role,
|
|
"--prompt", prompt,
|
|
]
|
|
env = dict(os.environ)
|
|
if "DELEGATE_JOB_PYTHON" not in env:
|
|
env["DELEGATE_JOB_PYTHON"] = sys.executable
|
|
|
|
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
|
|
if res.returncode != 0:
|
|
log_error(f"delegate_job submit failed (exit {res.returncode}):\n{res.stderr or res.stdout}")
|
|
return None
|
|
|
|
# Extract Job ID (pattern: 'registered job: [A-Za-z0-9]*')
|
|
match = re.search(r"registered job:\s*([A-Za-z0-9]+)", res.stdout)
|
|
if match:
|
|
job_id = match.group(1)
|
|
self.created_jobs.append(job_id)
|
|
self._record_checkpoint()
|
|
return job_id
|
|
return None
|
|
|
|
def wait_for_job(self, job_id: str, max_wait: int = 3900, check_interval: float = 1.0) -> bool:
|
|
"""Blocking wait for job completion or error with safety timeout."""
|
|
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"
|
|
start_time = time.time()
|
|
|
|
while time.time() - start_time < max_wait:
|
|
if self.interrupted:
|
|
return False
|
|
status = "unknown"
|
|
if job_file.exists():
|
|
try:
|
|
with open(job_file, "r", encoding="utf-8") as f:
|
|
status = json.load(f).get("status", "unknown")
|
|
except Exception:
|
|
status = "unknown"
|
|
|
|
if status == "completed":
|
|
if self.verbose:
|
|
log_success(f"Job '{job_id}' completed successfully.")
|
|
return True
|
|
elif status in ("error", "cancelled"):
|
|
log_error(f"Job '{job_id}' finished with status '{status}'.")
|
|
return False
|
|
|
|
time.sleep(check_interval)
|
|
|
|
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
|
|
return False
|
|
|
|
def find_report(self, job_id: str) -> Optional[Path]:
|
|
"""Locate report-final.md for a job."""
|
|
job_dir = self.mam_dir / "jobs" / job_id
|
|
matches = list(job_dir.glob("*/report-final.md")) + list(job_dir.glob("report-final.md"))
|
|
if matches:
|
|
return matches[0]
|
|
return None
|
|
|
|
def collect_changes_diff(self) -> str:
|
|
"""Collect git diff since base commit."""
|
|
diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
|
|
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))
|
|
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))
|
|
return res.stdout
|
|
except Exception:
|
|
return ""
|
|
|
|
def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
|
|
data = {
|
|
"iteration": iteration,
|
|
"created_jobs": list(self.created_jobs),
|
|
"current_plan": self.current_plan,
|
|
"plan_job_id": self.plan_job_id,
|
|
"review_status": review_status or {},
|
|
"base_commit": self.base_commit,
|
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
}
|
|
checkpoint.save_checkpoint(self.checkpoint_file, data)
|
|
|
|
def release_guard(self) -> None:
|
|
"""Release MAM_LOOP_MARKER and clean up freeze dir if owned."""
|
|
marker = os.environ.get("MAM_LOOP_MARKER") or str(self.mam_dir / "loop-guard-active")
|
|
if os.path.isfile(marker):
|
|
try:
|
|
with open(marker, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
if f"pid={os.getpid()}" in content:
|
|
os.unlink(marker)
|
|
except OSError:
|
|
pass
|
|
|
|
freeze_dir = os.environ.get("MAM_LOOP_FREEZE_DIR")
|
|
if freeze_dir and os.environ.get("MAM_LOOP_FREEZE_OWNED") == "1":
|
|
if "mam-loop-freeze." in freeze_dir and os.path.isdir(freeze_dir):
|
|
shutil.rmtree(freeze_dir, ignore_errors=True)
|
|
|
|
def run(self) -> int:
|
|
self.parse_args()
|
|
|
|
atexit.register(self.release_guard)
|
|
|
|
# Signal handling
|
|
def _handle_signal(signum, frame):
|
|
self.interrupted = True
|
|
log_warn(f"Received signal {signum}. Quiescing and saving checkpoint...")
|
|
self._record_checkpoint()
|
|
self.release_guard()
|
|
sys.exit(128 + signum)
|
|
|
|
signal.signal(signal.SIGINT, _handle_signal)
|
|
signal.signal(signal.SIGTERM, _handle_signal)
|
|
|
|
log_info("Initializing multi-agent-mux-loop controller...")
|
|
log_info(f"Target Agent: {self.creator}")
|
|
log_info(f"Task Goal: {self.task}")
|
|
|
|
state = self.load_sessions_state()
|
|
|
|
# Validate target agent session
|
|
target_status = None
|
|
for s in state.get("herdr_sessions", []):
|
|
if s.get("name") == self.creator:
|
|
target_status = s.get("status")
|
|
break
|
|
|
|
if not target_status:
|
|
log_error(f"Target agent session '{self.creator}' is not registered in the session registry.")
|
|
return 1
|
|
elif target_status != "running":
|
|
log_error(f"Target agent session '{self.creator}' is not running (current status: '{target_status}'). Please start it first.")
|
|
return 1
|
|
|
|
# Resolve Planner session
|
|
planner_session = ""
|
|
if self.planner_override:
|
|
planner_session = self.planner_override
|
|
p_status = None
|
|
for s in state.get("herdr_sessions", []):
|
|
if s.get("name") == planner_session:
|
|
p_status = s.get("status")
|
|
break
|
|
if not p_status:
|
|
log_error(f"Specified planner session '{planner_session}' is not registered in the session registry.")
|
|
return 1
|
|
elif p_status != "running":
|
|
log_error(f"Specified planner session '{planner_session}' is not running (current status: '{p_status}').")
|
|
return 1
|
|
else:
|
|
planner_session = self.resolve_planner_session(state)
|
|
if self.plan_mode and not planner_session:
|
|
log_error("Planner mode enabled (--plan) but no running session with a 'planner' role was found.")
|
|
return 1
|
|
|
|
log_info(f"Resolved Planner session: {planner_session}")
|
|
|
|
# =======================================================================
|
|
# PHASE 1: PLANNING & DISCUSSIONS
|
|
# =======================================================================
|
|
if self.plan_mode:
|
|
log_info("=== Phase 1: Interactive Planning Phase ===")
|
|
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)
|
|
if not p_jid:
|
|
log_error("Failed to register planner job.")
|
|
return 1
|
|
self.plan_job_id = p_jid
|
|
log_info(f"Planner Job ID: {p_jid}")
|
|
|
|
if not self.wait_for_job(p_jid):
|
|
log_error("Planning phase failed during initial plan design.")
|
|
return 1
|
|
|
|
p_file = self.find_report(p_jid)
|
|
if not p_file or not p_file.exists():
|
|
log_error("Final plan file not found.")
|
|
return 1
|
|
self.current_plan = p_file.read_text(encoding="utf-8", errors="replace")
|
|
|
|
# Interactive turns
|
|
creator_type = self.resolve_agent_type(self.creator, state)
|
|
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)
|
|
if not c_jid or not self.wait_for_job(c_jid):
|
|
log_error("Creator critique step failed.")
|
|
return 1
|
|
|
|
c_file = self.find_report(c_jid)
|
|
critique_text = c_file.read_text(encoding="utf-8", errors="replace") if c_file else ""
|
|
|
|
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)
|
|
if not ref_jid or not self.wait_for_job(ref_jid):
|
|
log_error("Planner refinement step failed.")
|
|
return 1
|
|
|
|
ref_file = self.find_report(ref_jid)
|
|
if ref_file and ref_file.exists():
|
|
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
|
|
|
|
log_success("Interactive planning completed. Plan finalized.")
|
|
else:
|
|
log_info("=== Phase 1: Self-Planning Mode (Direct Execution) ===")
|
|
if planner_session:
|
|
plan_files = sorted(glob.glob(str(self.workspace_root / ".agents" / "reports" / planner_session / "plan-*.md")), key=os.path.getmtime, reverse=True)
|
|
if plan_files:
|
|
log_info(f"Found existing promoted plan at '{plan_files[0]}'. Loading plan...")
|
|
self.current_plan = Path(plan_files[0]).read_text(encoding="utf-8", errors="replace")
|
|
|
|
# =======================================================================
|
|
# 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}"
|
|
else:
|
|
exec_prompt = f"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"
|
|
|
|
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 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)
|
|
# =======================================================================
|
|
log_info("=== Phase 3: Verification & Corrective Review Loop ===")
|
|
|
|
reviewers: List[str] = []
|
|
if self.all_reviewers:
|
|
reviewers = self.resolve_all_reviewers(state)
|
|
if not reviewers:
|
|
log_error("--all-reviewer was specified, but no running reviewer sessions were found.")
|
|
return 1
|
|
elif self.reviewer_list:
|
|
reviewers = [r.strip() for r in self.reviewer_list.split(",") if r.strip()]
|
|
|
|
rebut_total_budget = self.max_rebut * self.max_loop
|
|
|
|
final_passed_reviewers: Dict[str, Tuple[str, Path]] = {}
|
|
|
|
for loop_count in range(1, self.max_loop + 1):
|
|
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
|
|
rebut_budget = self.max_rebut
|
|
self._record_checkpoint(iteration=loop_count)
|
|
|
|
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)
|
|
if not sr_jid:
|
|
log_error("Failed to register Self-Review job.")
|
|
return 1
|
|
self.wait_for_job(sr_jid)
|
|
sr_report = self.find_report(sr_jid)
|
|
if sr_report and parser.has_verdict(sr_report, "PASS") and not parser.has_verdict(sr_report, "NOT PASS"):
|
|
log_success("Self-Review PASS.")
|
|
break
|
|
else:
|
|
log_warn("Self-Review NOT PASS.")
|
|
if loop_count == self.max_loop:
|
|
log_error("Reached max loop count. Self-review loop aborted with failures.")
|
|
return 1
|
|
continue
|
|
|
|
log_info(f"Active reviewers: {' '.join(reviewers)}")
|
|
changes_diff = self.collect_changes_diff()
|
|
|
|
review_jobs: List[Tuple[str, str]] = [] # (rev, job_id)
|
|
for rev in reviewers:
|
|
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)
|
|
if not r_jid:
|
|
log_error(f"Failed to register review job for '{rev}'.")
|
|
return 1
|
|
review_jobs.append((rev, r_jid))
|
|
|
|
all_passed = True
|
|
fail_revs: List[str] = []
|
|
fail_reports: List[Path] = []
|
|
feedback_parts: List[str] = []
|
|
|
|
for rev, jid in review_jobs:
|
|
if not self.wait_for_job(jid):
|
|
log_warn(f"Reviewer '{rev}' job crashed.")
|
|
all_passed = False
|
|
continue
|
|
|
|
r_file = self.find_report(jid)
|
|
if not r_file or not r_file.exists():
|
|
log_warn(f"Reviewer '{rev}' report not found. Counting as NOT PASS.")
|
|
all_passed = False
|
|
continue
|
|
|
|
content = r_file.read_text(encoding="utf-8", errors="replace")
|
|
verdict = parser.parse_verdict(content)
|
|
|
|
if verdict != "PASS":
|
|
log_warn(f"Reviewer '{rev}': NOT PASS")
|
|
all_passed = False
|
|
fail_revs.append(rev)
|
|
fail_reports.append(r_file)
|
|
feedback_parts.append(f"\n--- Reviewer ({rev}) Feedback ---\n{content}")
|
|
else:
|
|
log_success(f"Reviewer '{rev}': PASS")
|
|
final_passed_reviewers[rev] = (jid, r_file)
|
|
|
|
if all_passed:
|
|
log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.")
|
|
break
|
|
|
|
if loop_count == self.max_loop:
|
|
log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.")
|
|
return 1
|
|
|
|
feedback_aggregate = "".join(feedback_parts)
|
|
complex_fix = any(parser.has_tag_line(rf, "ESCALATE: PLANNER") for rf in fail_reports)
|
|
|
|
if self.plan_mode and complex_fix:
|
|
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)
|
|
if not ref_jid or not self.wait_for_job(ref_jid):
|
|
log_error("Failed to register plan refinement job.")
|
|
return 1
|
|
ref_file = self.find_report(ref_jid)
|
|
if ref_file and ref_file.exists():
|
|
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
|
|
correction_prompt = f"갱신된 다음 계획서에 입각하여 지적된 오류들을 수정하고 코드를 다시 구현해주세요. 계획서:\n{self.current_plan}\n피드백 상세:\n{feedback_aggregate}"
|
|
else:
|
|
log_info("Applying straight bugfixes based on reviewer feedback...")
|
|
correction_prompt = f"리뷰어들이 지적한 다음 피드백에 입각하여 코드를 수정해주세요. 피드백:\n{feedback_aggregate}"
|
|
|
|
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)
|
|
if not correct_jid or not self.wait_for_job(correct_jid):
|
|
log_error("Failed to register Creator correction job.")
|
|
return 1
|
|
|
|
# -----------------------------------------------------------------
|
|
# REBUTTAL & ADJUDICATION ROUND
|
|
# -----------------------------------------------------------------
|
|
if self.max_rebut > 0 and rebut_budget > 0 and rebut_total_budget > 0:
|
|
correct_report = self.find_report(correct_jid)
|
|
rebut_targets: List[str] = []
|
|
if correct_report:
|
|
rebut_tags = parser.extract_tag_values(correct_report, "REBUT")
|
|
for t in rebut_tags:
|
|
if t in fail_revs and t not in rebut_targets:
|
|
rebut_targets.append(t)
|
|
|
|
if rebut_targets:
|
|
rebut_budget -= 1
|
|
rebut_total_budget -= 1
|
|
rebuttal_text = correct_report.read_text(encoding="utf-8", errors="replace") if correct_report else ""
|
|
log_warn(f"Creator filed a rebuttal against: {' '.join(rebut_targets)} (iteration budget left: {rebut_budget}, run total left: {rebut_total_budget})")
|
|
|
|
overruled_revs: List[str] = []
|
|
for rt in rebut_targets:
|
|
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)
|
|
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)
|
|
continue
|
|
|
|
adj_file = self.find_report(adj_jid)
|
|
if adj_file and parser.has_tag_line(adj_file, "ADJUDICATION: SUSTAINED"):
|
|
log_success(f"Reviewer '{rt}' SUSTAINED the rebuttal; objection withdrawn.")
|
|
else:
|
|
log_warn(f"Reviewer '{rt}' OVERRULED the rebuttal; objection stands.")
|
|
overruled_revs.append(rt)
|
|
|
|
if overruled_revs:
|
|
arbitration = "REVIEWER"
|
|
if self.plan_mode and planner_session:
|
|
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)
|
|
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"):
|
|
arbitration = "CREATOR"
|
|
else:
|
|
log_warn("Planner arbitration job failed; defaulting to REVIEWER.")
|
|
|
|
if arbitration == "CREATOR":
|
|
log_success("Planner ruled for the Creator; objections withdrawn for this round.")
|
|
else:
|
|
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)
|
|
if comp_jid:
|
|
self.wait_for_job(comp_jid)
|
|
|
|
# Promotion
|
|
if self.plan_mode and self.current_plan and self.plan_job_id:
|
|
plan_dest_dir = self.workspace_root / ".agents" / "reports" / planner_session
|
|
log_info(f"Promoting final plan to durable location: {plan_dest_dir}")
|
|
plan_dest_dir.mkdir(parents=True, exist_ok=True)
|
|
tmp_plan = plan_dest_dir / f"plan-{self.plan_job_id}.md.tmp"
|
|
tmp_plan.write_text(self.current_plan, encoding="utf-8")
|
|
tmp_plan.replace(plan_dest_dir / f"plan-{self.plan_job_id}.md")
|
|
|
|
if final_passed_reviewers:
|
|
log_info("Promoting final review reports to durable location...")
|
|
for rev, (jid, rep_file) in final_passed_reviewers.items():
|
|
dest_dir = self.workspace_root / ".agents" / "reports" / rev
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
dest_file = dest_dir / f"report-{jid}.md"
|
|
tmp_dest = dest_dir / f"report-{jid}.md.tmp"
|
|
shutil.copyfile(rep_file, tmp_dest)
|
|
tmp_dest.replace(dest_file)
|
|
|
|
# Cleanup
|
|
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
|
|
if job_dir.exists():
|
|
shutil.rmtree(job_dir, ignore_errors=True)
|
|
sub_out = self.mam_dir / "jobs" / f"{job}.subscriber.out"
|
|
if sub_out.exists():
|
|
sub_out.unlink(missing_ok=True)
|
|
if self.verbose:
|
|
log_info(f"Purged temp assets for job: {job}")
|
|
log_success("Cleanup complete.")
|
|
|
|
checkpoint.remove_checkpoint(self.checkpoint_file)
|
|
log_success("Mux loop finished with 100% PASS verdicts.")
|
|
return 0
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
runner = LoopRunner(argv)
|
|
return runner.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|