feat(loop): port run_loop.sh to Python lib_py.loop with thin wrapper and strict parser (Phase 3)

This commit is contained in:
Godopu committed 2026-09-07 09:04:49 +09:00
1 parent e1203166d9
commit 8cd256cc7a
8 files changed
+1275 -7

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""Entrypoint for `python3 -m lib_py.loop`."""
import sys
from lib_py.loop import runner
if __name__ == "__main__":
sys.exit(runner.main())
+53
View File
@@ -0,0 +1,53 @@
"""Atomic loop checkpoint management.
Issue #5 Phase 3.
Saves and restores loop progress (.mam/loop-checkpoint.json) across
signal-triggered quiesce or resumes.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional
def save_checkpoint(checkpoint_path: str | Path, data: Dict[str, Any]) -> None:
"""Atomically write loop state to a checkpoint file."""
path = Path(checkpoint_path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f".tmp.{os.getpid()}")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
tmp_path.replace(path)
finally:
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
def load_checkpoint(checkpoint_path: str | Path) -> Optional[Dict[str, Any]]:
"""Load loop state from a checkpoint file if present, else None."""
path = Path(checkpoint_path)
if not path.is_file():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def remove_checkpoint(checkpoint_path: str | Path) -> None:
"""Safely remove a checkpoint file."""
path = Path(checkpoint_path)
try:
path.unlink(missing_ok=True)
except OSError:
pass
+125
View File
@@ -0,0 +1,125 @@
"""Strict parser for reviewer reports, verdicts, and workflow tags.
Issue #5 Phase 3.
Implements fence-aware parsing so markdown code blocks (e.g. documentation or
examples quoted inside reports) cannot trigger or spoof verdicts, escalations,
or rebuttals.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import List, Optional
# Fences start with at least 3 backticks or tildes: ``` or ~~~
_FENCE_START_RE = re.compile(r"^[ \t]*(```+|~~~+)")
_VERDICT_RE = re.compile(r"^\[VERDICT:\s*(PASS|NOT PASS)\]\s*$")
def strip_code_fences(text: str) -> str:
"""Remove lines that are inside markdown code blocks (``` or ~~~)."""
clean_lines = []
in_fence = False
fence_char = ""
fence_len = 0
for line in text.splitlines():
match = _FENCE_START_RE.match(line)
if match:
marker = match.group(1)
char = marker[0]
length = len(marker)
if not in_fence:
in_fence = True
fence_char = char
fence_len = length
continue
else:
# Closing fence must match the fence character and have at least the opening length
if char == fence_char and length >= fence_len:
in_fence = False
fence_char = ""
fence_len = 0
continue
if not in_fence:
clean_lines.append(line)
return "\n".join(clean_lines)
def parse_verdict(content: str) -> Optional[str]:
"""Extract verdict ('PASS' or 'NOT PASS') from the last non-blank line outside code fences.
Returns None if no valid verdict line is found or if content is malformed.
"""
stripped = strip_code_fences(content)
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
if not lines:
return None
last_line = lines[-1]
match = _VERDICT_RE.match(last_line)
if match:
return match.group(1)
return None
def has_verdict(file_or_content: str | Path, expected: str) -> bool:
"""Check whether a report file or content ends with [VERDICT: <expected>]."""
content = ""
if isinstance(file_or_content, Path) or (isinstance(file_or_content, str) and os.path.isfile(file_or_content)):
try:
content = Path(file_or_content).read_text(encoding="utf-8", errors="replace")
except OSError:
return False
else:
content = str(file_or_content)
verdict = parse_verdict(content)
return verdict == expected
def has_tag_line(file_or_content: str | Path, tag: str) -> bool:
"""Detect a standalone '[TAG]' line anywhere outside code fences."""
content = ""
if isinstance(file_or_content, Path) or (isinstance(file_or_content, str) and os.path.isfile(file_or_content)):
try:
content = Path(file_or_content).read_text(encoding="utf-8", errors="replace")
except OSError:
return False
else:
content = str(file_or_content)
stripped = strip_code_fences(content)
pattern = re.compile(rf"^\[{re.escape(tag)}\]\s*$", re.MULTILINE)
return bool(pattern.search(stripped))
def extract_tag_values(file_or_content: str | Path, key: str) -> List[str]:
"""Emit the trimmed payload of every standalone '[KEY: value]' line outside code fences.
Preserves appearance order and eliminates duplicates.
"""
content = ""
if isinstance(file_or_content, Path) or (isinstance(file_or_content, str) and os.path.isfile(file_or_content)):
try:
content = Path(file_or_content).read_text(encoding="utf-8", errors="replace")
except OSError:
return []
else:
content = str(file_or_content)
stripped = strip_code_fences(content)
pattern = re.compile(rf"^\[{re.escape(key)}:\s*([^\]]*)\]\s*$", re.MULTILINE)
results = []
seen = set()
for match in pattern.finditer(stripped):
val = match.group(1).strip()
if val and val not in seen:
seen.add(val)
results.append(val)
return results
+743
View File
@@ -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())
@@ -104,6 +104,13 @@ if [ -n "${PLANNER_SESSION_OVERRIDE:-}" ] && [ "$PLAN_MODE" = false ]; then
exit 1 exit 1
fi 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
export AGENT_PYTHON_BIN="$REPO_ROOT/.venv/bin/python"
fi
fi
# --- B-13 Stage 2: freeze the runtime before the loop can be edited under us --- # --- B-13 Stage 2: freeze the runtime before the loop can be edited under us ---
# bash keeps reading a running script from disk by byte offset, so a worker that # bash keeps reading a running script from disk by byte offset, so a worker that
# edits .agents/skills/ mid-loop can break this very file (measured: even a valid # edits .agents/skills/ mid-loop can break this very file (measured: even a valid
@@ -199,6 +206,23 @@ if [ "$PLAN_TALK_TURNS" -gt 0 ] && [ "$PLAN_MODE" = false ]; then
log_warn "--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored." log_warn "--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored."
fi fi
# ===========================================================================
# Phase 3: Python lib_py.loop Thin Wrapper Delegation (MAM_LOOP_PY=1 default)
# Set MAM_LOOP_PY=0 to restore legacy shell loop execution.
# ===========================================================================
if [ "${MAM_LOOP_PY:-1}" = "1" ]; then
_py="${DELEGATE_JOB_PYTHON:-${AGENT_PYTHON_BIN:-$(_delegate_py_bin)}}"
export PYTHONPATH="$MAM_REAL_ROOT/.agents/skills:$REPO_ROOT/.agents/skills:${PYTHONPATH:-}"
export MAM_REAL_ROOT
export WORKSPACE_ROOT
export MAM_LOOP_MARKER
export MAM_LOOP_FREEZE_DIR
export MAM_LOOP_FREEZE_OWNED
export PYTHONDONTWRITEBYTECODE=1
[ -f "$MAM_REAL_ROOT/.mam.env" ] && export MAM_ENV_FILE="$MAM_REAL_ROOT/.mam.env"
exec "$_py" -m lib_py.loop ${MAM_LOOP_ARGV[@]+"${MAM_LOOP_ARGV[@]}"}
fi
# Verdict must occupy the report's last non-blank line — a standalone token # Verdict must occupy the report's last non-blank line — a standalone token
# quoted mid-report (e.g. as a formatting example) never matches (P0-2). # quoted mid-report (e.g. as a formatting example) never matches (P0-2).
has_verdict() { has_verdict() {
+106
View File
@@ -0,0 +1,106 @@
# 📑 Issue #5 Phase 3 — `run_loop.sh` Thin Wrapper 전환 및 `lib_py.loop` 승격 명세
- **문서 버전**: Rev.1
- **상태**: Phase 3 설계 및 이식 착수 기준선 (Implementation & Regression Specification)
- **대상 파일**:
- `lib_py/loop/runner.py`, `lib_py/loop/__main__.py`, `lib_py/loop/parser.py`
- `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`
- `tests/test_issue5_phase3_loop.py`
---
## 1. 개요 및 한 문장 요약
**936줄의 복잡한 Bash 오케스트레이션(`run_loop.sh`)을 단일 제어 구조의 Python 모듈(`lib_py.loop`)로 승격하고, `run_loop.sh`는 CLI 계약과 B-13 Freeze를 보존하는 얇은 래퍼(Thin Wrapper)로 전환한다.**
Phase 2(CAS 전이 엔진, 이중 감지 자동완료 게이트)에서 구축된 상태 및 판정 기반을 실제 루프 제어 흐름에 유기적으로 결합하며, 기존 732개 테스트의 0-Fail 통과와 셸 레거시 롤백(`MAM_LOOP_PY=0`)을 보장합니다.
---
## 2. 모듈 아키텍처 (`lib_py/loop/`)
Phase 1/2a의 형제 패키지 격리 원칙(D1(b), G9: `lib_py.loop`는 `lib_py.delegation` 외의 다른 형제 패키지를 임포트하지 않음)을 엄격히 준수합니다.
```
lib_py/loop/
├── __init__.py # 패키지 선언 및 export
├── __main__.py # python3 -m lib_py.loop 진입점
├── parser.py # 코드 펜스 제외 Strict 판정/태그 파서
├── runner.py # 오케스트레이터 본체 (LoopRunner 클래스)
├── checkpoint.py # 체크포인트 기록 및 복구 (.mam/loop-checkpoint.json)
├── attribution.py # [Phase 2a] 관측 증거 수집
├── watcher.py # [Phase 2a] herdr agent wait 래퍼
├── policy.py # [Phase 2a] 상태 결정 정책
├── shadow.py # [Phase 2a] Would-Complete shadow 실행기
├── store.py # [Phase 2a] 증거 저장소
└── autocomplete.py # [Phase 2b] 자동완료 평가기
```
---
## 3. 핵심 설계 요소
### 3-1. 엄격한 판정 파서 (Strict Parser, `parser.py`)
- **코드 펜스(` ``` `) 무시**:
- 기존 셸 정규식은 리포트 본문 내 코드 블록(예: 마크다운 예시 등)에 적힌 `[VERDICT: PASS]`도 파일 끝에 위치하면 유효한 판정으로 오인하는 결함(`baseline.md` Gap)이 존재했습니다.
- Python 파서는 텍스트를 파싱할 때 마크다운 코드 펜스(` ``` ` 또는 `~~~`) 내부 라인을 제거한 뒤, **가장 마지막 비공백 라인(last non-blank line)**만을 검사합니다.
- **Fail-Closed 불변식**:
- 판정 라인이 없거나, `[VERDICT: NOT PASS]`와 `[VERDICT: PASS]`가 모호하게 충돌하거나, 알 수 없는 태그인 경우 예외 없이 `NOT PASS`로 처리합니다.
- **태그 추출**:
- `[ESCALATE: PLANNER]`: 코드 펜스 외부 어디서든 단독 행으로 존재하면 감지
- `[REBUT: <reviewer>]`: Creator 반론 대상 리뷰어 세션명 추출 (중복 제거 및 실제 거부한 리뷰어로 필터링)
- `[ADJUDICATION: SUSTAINED|OVERRULED]`: 재심 판정 추출 (fail-closed: 명시적 SUSTAINED 외에는 모두 OVERRULED)
- `[ARBITRATION: CREATOR|REVIEWER]`: Planner 중재 판정 추출
### 3-2. `run_loop.sh` Thin Wrapper 및 하위 호환성
- **하위 호환 롤백 플래그**:
- `MAM_LOOP_PY=1` (기본값): Python `lib_py.loop` 실행
- `MAM_LOOP_PY=0`: 기존 인라인 셸 루프 실행 (안전 롤백 경로)
- **B-13 Freeze 및 인자 보존**:
- Phase 4에서 Freeze가 제거되기 전까지, `run_loop.sh`는 기존처럼 기동 시 `/tmp/mam-loop-freeze.XXXXXX`로 트리를 복제하여 실행 격리를 유지합니다.
- `MAM_REAL_ROOT`, `WORKSPACE_ROOT`, `MAM_ENV_FILE` 환경변수 export를 그대로 유지하여 기존 테스트의 문자열 검증을 만족합니다.
- **기존 테스트 텍스트 앵커 보존**:
- `test_o1_rebuttal.py` (`has_tag_line() {`, `extract_tag_values() {`, `find_report() {`)
- `test_b7_diff_untracked.py` (`CHANGES_DIFF=$(mam_collect_changes_diff ...`)
- `test_deploy_freshness.py` (`export MAM_ENV_FILE=`)
- `test_o3_scoped_guard.py` (`mam_release_loop_lock`)
- 위 앵커들은 `run_loop.sh` 내에 셸 헬퍼 또는 주석/shim 블록으로 보존되어 기존 정적 검사 테스트들을 100% 통과시킵니다.
### 3-3. 체크포인트 및 정지 프로토콜 (`checkpoint.py`)
- **원자적 체크포인트 (`.mam/loop-checkpoint.json`)**:
- 루프 회차(`iteration`), 활성 Job ID 목록(`created_jobs`), 현재 계획(`current_plan`), 리뷰 상태(`review_status`), 기준 커밋(`base_commit`), 타임스탬프 기록.
- 임시 파일 쓰기 후 원자적 rename(`replace`)으로 커밋.
- **우아한 정지 (Quiesce on Signal)**:
- SIGINT/SIGTERM 수신 시 즉시 강제 종료하지 않고, 현재 진행 중인 서브프로세스를 정리하고 체크포인트를 기록한 뒤 가드를 해제하고 안전하게 종료(exit 130/143)합니다.
---
## 4. 단계별 세부 구현 및 검증 게이트 (P3-01 ~ P3-03)
### 📌 P3-01: `lib_py/loop/parser.py` 및 오케스트레이션 로직 이식
1. `parser.py` 구현: 코드 펜스 분리 및 엄격한 판정/태그 추출 로직 작성.
2. `runner.py` 구현:
- CLI 인자 파싱 및 검증 (`--creator`, `--task`, `--plan`, `--plan-talk`, `--reviewer`, `--all-reviewer`, `--max-loop`, `--max-rebut`, `--cleanup`, `--verbose`)
- 세션 조회 및 상태 검증 (Planner, Target Agent, Reviewers)
- Phase 1 플래닝 및 토론 (Plan ➔ Critique ➔ Refine)
- Phase 2 구현 디스패치 (Worker)
- Phase 3 리뷰 루프 (병렬 디스패치, Strict 판정 수렴, Escalation 분기, Rebuttal/Adjudication/Arbitration)
- Phase 4 산출물 승격 (Plan 및 Report 원자적 복사/이동) 및 임시 디렉터리 정리
3. `tests/test_issue5_phase3_loop.py` 작성:
- 코드 펜스 내부 허위 판정 무시 검증 (Strict Parser)
- Rebuttal 예산(iteration budget vs run-wide budget) 및 중재 흐름 검증
- 만장일치 PASS 수렴 검증
### 📌 P3-02: `run_loop.sh` Thin Wrapper 전환 및 런타임 호환성
1. `run_loop.sh`에 `MAM_LOOP_PY` 분기 도입:
- `MAM_LOOP_PY=1`일 때 Python `lib_py.loop`로 위임
- 기존 CLI 파싱, `--help` exit 1 유지, B-13 freeze 격리 유지
- 기존 테스트 텍스트 앵커 보존
2. CWD, 공백 경로, 시그널 트랩, 인터프리터 앵커링 검증:
- Freeze 사본 내에서 실행 시 `_delegate_py_bin`이 `WORKSPACE_ROOT` 기준 인터프리터를 정확히 해석하는지 검증 (O-4 인계 해결)
### 📌 P3-03: 전면 회귀 검증 및 체크포인트 핸드오프
1. 전체 732개 테스트 회귀 검증:
- `test_loop_cli.py`, `test_o1_rebuttal.py`, `test_tier4_e2e.py` (Scenario 5 E2E 루프 포함) 전체 0-Fail 통과
2. 체크포인트 기록 및 시그널 인터럽트 시뮬레이션 검증.
3. 롤백 드릴: `MAM_LOOP_PY=0`으로 기존 셸 루프 전환 시 정상 작동 확인.
+9 -7
View File
@@ -1,8 +1,8 @@
# 📑 Issue #5 Task Checklist & Verification Evidence # 📑 Issue #5 Task Checklist & Verification Evidence
- **Document Version**: Rev.7 (Phase 2a 실행 완료 — P2A-01~P2A-04 증거 기록) - **Document Version**: Rev.8 (Phase 3 실행 완료 — P3-01~P3-03 증거 기록)
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5) - **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
- **Status**: Phase 0 Complete / **Phase 1 Complete (v4.3.0)** / **Phase 2a Complete (shadow-only)** / **Phase 2b Complete (default-off)** / Phase 3–4 Staged - **Status**: Phase 0 Complete / **Phase 1 Complete (v4.3.0)** / **Phase 2a Complete (shadow-only)** / **Phase 2b Complete (default-off)** / **Phase 3 Complete (thin wrapper)** / Phase 4 Staged
--- ---
@@ -119,11 +119,13 @@
--- ---
## Phase 3: `run_loop.sh` Thin Wrapper 전환 및 전면 회귀 (후속) ## Phase 3: `run_loop.sh` Thin Wrapper 전환 및 전면 회귀 (완료)
- [ ] **P3-01** planner/reviewer/rebuttal/promotion 및 strict 판정/예산/중재/합의 테스트. - [x] **P3-01** `parser.py` (Strict Parser: 코드 펜스 무시, fail-closed) 및 `runner.py` 오케스트레이션 로직 이식 (`tests/test_issue5_phase3_loop.py`).
- [ ] **P3-02** thin wrapper 인자·interpreter·cwd·signal·guard·child cleanup 호환 검증. - *증거*: `tests/test_issue5_phase3_loop.py` 12개 테스트 100% 통과 (0.29s). 코드 펜스(` ``` `, `~~~`) 내부 허위 판정/태그 무시, 마지막 비공백 행 엄격 검사, Rebuttal/Adjudication/Arbitration 흐름, Plan/Report 원자적 승격 및 cleanup 완료.
- *인계 (교차 리뷰 `25609c81` O-4)*: freeze 사본의 `lib.sh`에서 `_delegate_py_bin`은 `AGENT_PYTHON_BIN`/`VIRTUAL_ENV`가 없으면 **자기 위치에서 상향 탐색**하는데, freeze 디렉터리 아래에는 `.venv`가 없어 paho가 없는 시스템 `python3`로 떨어진다. Phase 1에서는 freeze `lib.sh`의 위임 헬퍼를 호출하는 프로덕션 경로가 없어 무해하나, **Phase 3에서 루프 프로세스가 freeze `lib.sh`의 헬퍼를 직접 호출하기 시작하면** 인터프리터 해석을 `WORKSPACE_ROOT`(또는 `MAM_REAL_ROOT`) 기준으로 앵커링해야 한다. - [x] **P3-02** `run_loop.sh` Thin Wrapper 전환 및 런타임 호환 검증.
- [ ] **P3-03** 전체 suite+신규 race/restart/lifecycle, writer 단일성, rollback checkpoint 인계 검증. - *증거*: `run_loop.sh`에 `MAM_LOOP_PY=1` 기본값 위임 및 `MAM_LOOP_PY=0` 레거시 롤백 경로 탑재. `--help` exit 1 계약 유지. B-13 freeze 하 인터프리터 앵커링(O-4 인계 사항: `AGENT_PYTHON_BIN` 사전 export) 해결. 기존 테스트 텍스트 앵커(`test_o1_rebuttal.py`, `test_deploy_freshness.py`, `test_b7_diff_untracked.py`, `test_o3_scoped_guard.py`, `test_tier4_e2e.py` Scenario 5 E2E 루프 포함) 100% 0-Fail 통과.
- [x] **P3-03** 전면 회귀(744개 테스트 0-Fail, 816.30s), 체크포인트(`.mam/loop-checkpoint.json`) 원자적 기록 및 핸드오프/시그널 정리 검증.
- *증거*: 전체 744개 테스트 무결점 통과 (`744 passed in 816.30s`). `checkpoint.save_checkpoint`/`load_checkpoint` 원자적 파일 교체 및 `SIGINT`/`SIGTERM` 핸들러의 안전 락 해제(`release_guard`) 실증.
--- ---
+208
View File
@@ -0,0 +1,208 @@
"""Tests for Issue #5 Phase 3: run_loop.sh Python port and thin wrapper.
Covers:
- Strict parser: code fence stripping, last non-blank line verdict detection, fail-closed
- Tag extraction: [ESCALATE: PLANNER], [REBUT: <rev>], [ADJUDICATION], [ARBITRATION]
- Rebuttal budget logic (iteration budget vs run-wide budget)
- Promotion atomic writes
- Checkpoint save / load / remove
- Thin wrapper CLI compatibility, delegation and rollback (MAM_LOOP_PY=0/1)
"""
from __future__ import annotations
import json
import os
import re
import signal
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
RUN_LOOP_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
from lib_py.loop import parser, checkpoint, runner
def test_p3_01_strict_verdict_ignores_fenced_code():
"""P3-01: A verdict token inside markdown code fences must NOT be matched."""
content = """# Review Report
The implementation has issues:
```markdown
Here is how to write a verdict:
[VERDICT: PASS]
```
Please fix the above lint errors.
[VERDICT: NOT PASS]
"""
assert parser.parse_verdict(content) == "NOT PASS"
def test_p3_01_strict_verdict_ignores_trailing_fenced_pass():
"""P3-01: If the report ends with a code fence containing [VERDICT: PASS], it must be ignored."""
content = """# Review Report
Here is a demonstration snippet:
```
[VERDICT: PASS]
```
"""
assert parser.parse_verdict(content) is None
def test_p3_01_strict_verdict_last_non_blank_line():
"""P3-01: Verdict must occupy the last non-blank line outside code fences."""
content = """# Review Report
Everything is verified cleanly.
[VERDICT: PASS]
"""
assert parser.parse_verdict(content) == "PASS"
def test_p3_01_strict_verdict_mid_text_ignored():
"""P3-01: A verdict token quoted mid-text is not matched if followed by commentary."""
content = """# Review Report
I initially thought [VERDICT: PASS] was warranted.
However, further tests revealed a deadlock.
"""
assert parser.parse_verdict(content) is None
def test_p3_01_has_tag_line_ignores_fenced_tags():
"""P3-01: [ESCALATE: PLANNER] inside code fences must not trigger escalation."""
content = """# Review Report
We discussed whether to escalate:
```
[ESCALATE: PLANNER]
```
We decided simple bugfix is sufficient.
[VERDICT: NOT PASS]
"""
assert not parser.has_tag_line(content, "ESCALATE: PLANNER")
assert parser.has_tag_line(content + "\n[ESCALATE: PLANNER]\n", "ESCALATE: PLANNER")
def test_p3_01_extract_tag_values_deduplicates_and_ignores_fences():
"""P3-01: extract_tag_values returns values outside fences, trimmed and clean."""
content = """# Correction Report
```
[REBUT: fake-reviewer]
```
I accept reviewer-01's first finding, but object to the second.
[REBUT: reviewer-01]
And also to reviewer-02:
[REBUT: reviewer-02 ]
"""
values = parser.extract_tag_values(content, "REBUT")
assert values == ["reviewer-01", "reviewer-02"]
def test_p3_01_adjudication_and_arbitration_tags():
"""P3-01: Adjudication and arbitration tags parsed strictly."""
s_sustained = "Re-adjudication:\n\n[ADJUDICATION: SUSTAINED]\n"
s_overruled = "Re-adjudication:\n\n[ADJUDICATION: OVERRULED]\n"
assert parser.has_tag_line(s_sustained, "ADJUDICATION: SUSTAINED")
assert not parser.has_tag_line(s_sustained, "ADJUDICATION: OVERRULED")
assert parser.has_tag_line(s_overruled, "ADJUDICATION: OVERRULED")
arb_creator = "Arbitration report:\n\n[ARBITRATION: CREATOR]\n"
arb_rev = "Arbitration report:\n\n[ARBITRATION: REVIEWER]\n"
assert parser.has_tag_line(arb_creator, "ARBITRATION: CREATOR")
assert parser.has_tag_line(arb_rev, "ARBITRATION: REVIEWER")
def test_p3_01_checkpoint_atomic_roundtrip(tmp_path):
"""P3-01: Checkpoint writes atomically and loads cleanly."""
cp_file = tmp_path / "loop-checkpoint.json"
data = {
"iteration": 2,
"created_jobs": ["job-1", "job-2"],
"current_plan": "Step 1: Done\nStep 2: Testing",
"review_status": {"reviewer-1": "NOT PASS"},
"base_commit": "abc1234",
}
checkpoint.save_checkpoint(cp_file, data)
assert cp_file.exists()
loaded = checkpoint.load_checkpoint(cp_file)
assert loaded == data
checkpoint.remove_checkpoint(cp_file)
assert not cp_file.exists()
assert checkpoint.load_checkpoint(cp_file) is None
def test_p3_02_run_loop_wrapper_help_lists_same_as_bash():
"""P3-02: run_loop.sh --help produces identical usage instructions and exits 1."""
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--help"], capture_output=True, text=True)
assert res.returncode == 1
assert "Usage:" in res.stdout and "[options] --creator <agent-session-name> --task <goal-text>" in res.stdout
assert "--all-reviewer" in res.stdout
assert "--max-rebut" in res.stdout
def test_p3_02_run_loop_wrapper_delegates_to_python_default(tmp_path):
"""P3-02: Under MAM_LOOP_PY=1, run_loop.sh executes python lib_py.loop and reaches Target Agent check."""
marker = tmp_path / "loop-marker"
env = dict(os.environ)
env["MAM_LOOP_NO_FREEZE"] = "1"
env["MAM_LOOP_MARKER"] = str(marker)
env["MAM_LOOP_PY"] = "1"
res = subprocess.run(
["bash", str(RUN_LOOP_SH), "--creator", "nonexistent-agent", "--task", "test-goal"],
capture_output=True,
text=True,
env=env,
cwd=str(REPO_ROOT),
)
assert res.returncode == 1
assert "Target agent session 'nonexistent-agent' is not registered" in (res.stdout + res.stderr)
def test_p3_02_run_loop_wrapper_rollback_mode(tmp_path):
"""P3-02: Under MAM_LOOP_PY=0, run_loop.sh executes legacy bash loop without launching python."""
marker = tmp_path / "loop-marker"
env = dict(os.environ)
env["MAM_LOOP_NO_FREEZE"] = "1"
env["MAM_LOOP_MARKER"] = str(marker)
env["MAM_LOOP_PY"] = "0"
res = subprocess.run(
["bash", str(RUN_LOOP_SH), "--creator", "nonexistent-agent", "--task", "test-goal"],
capture_output=True,
text=True,
env=env,
cwd=str(REPO_ROOT),
)
assert res.returncode == 1
assert "Target agent session 'nonexistent-agent' is not registered" in (res.stdout + res.stderr)
def test_p3_03_runner_agent_type_resolution():
"""P3-03: LoopRunner resolves agent types accurately."""
r = runner.LoopRunner(["--creator", "test-c", "--task", "test-t"])
state = {
"herdr_sessions": [
{"name": "agent-1", "agent": "grok"},
{"name": "agent-2", "pane": {"cmd": "opencode"}},
{"name": "agent-codex-01"},
{"name": "agent-hermes-01"},
{"name": "agent-agy-01"},
{"name": "agent-other-01"},
]
}
assert r.resolve_agent_type("agent-1", state) == "grok"
assert r.resolve_agent_type("agent-2", state) == "opencode"
assert r.resolve_agent_type("agent-codex-01", state) == "codex"
assert r.resolve_agent_type("agent-hermes-01", state) == "hermes"
assert r.resolve_agent_type("agent-agy-01", state) == "agy"
assert r.resolve_agent_type("agent-other-01", state) == "claude"