feat(loop): port run_loop.sh to Python lib_py.loop with thin wrapper and strict parser (Phase 3)
This commit is contained in:
1 parent
e1203166d9
commit
8cd256cc7a
8 files changed
+1275
-7
No files matched your search
@@ -0,0 +1,743 @@
|
||||
"""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 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, 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):
|
||||
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()
|
||||
|
||||
import atexit
|
||||
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())
|
||||
Reference in new issue
Block a user