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,7 @@
|
|||||||
|
"""Entrypoint for `python3 -m lib_py.loop`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from lib_py.loop import runner
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(runner.main())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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`으로 기존 셸 루프 전환 시 정상 작동 확인.
|
||||||
@@ -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`) 실증.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in new issue
Block a user