feat(issue-21): loop runner --worktree/--base/--review-only with a fail-closed empty change-set guard; control plane stays in the main checkout

- --worktree <path> (CLI over MAM_LOOP_WORKTREE): the source root. Only the
  diff, base, head and the empty-set check use it; the wrapper, registry,
  checkpoint, loop guard, promotion and cleanup stay on the control root. The
  collector is the control root's diff_collect.sh (never the worktree's
  unreviewed copy), its arguments shlex-quoted. Validation: an existing
  directory, the top of its working tree, the same git common dir as the
  control root (".git" is resolved against the directory it came from).
- --base <ref> (CLI only, no env - a stale sha would silently widen the next
  range): resolved to a full sha, must be an ancestor of the source HEAD.
- --review-only: requires --base, refuses --plan; Phase 2 is skipped and
  logged; correction and comply jobs still go to the creator.
- With --worktree or --base every delegated prompt starts with a context
  block (path, branch, base and its origin, head, base..head commit count,
  uncommitted changes); without them prompts are byte-identical. All call
  sites in run() go through one _dispatch helper.
- Fail-closed guard (only with --worktree/--base): before each review round,
  self-review included, an empty change set or one git cannot determine
  (exit codes, never output parsing) ends the loop with rc 1 instead of
  asking for reviews. This closes the fail-open where a loop started after
  an out-of-loop commit sent reviewers "(no changes since base commit)".
- The runner honours MAM_JOBS_DIR and passes --registry-dir to the wrapper.
- run_loop.sh accepts the three options; the legacy shell loop
  (MAM_LOOP_PY=0) refuses them before the freeze and the loop lock.

Tests T2-1..T2-14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-14 03:21:43 +00:00
1 parent d3d3a76c83
commit d907359746
4 files changed
+539 -37

No files matched your search

+216 -36
View File
@@ -13,6 +13,7 @@ import glob
import json
import os
import re
import shlex
import shutil
import signal
import sqlite3
@@ -20,7 +21,7 @@ import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple
from lib_py.loop import checkpoint, eager, parser
@@ -58,6 +59,18 @@ class LoopRunner:
self.workspace_root = Path(os.environ.get("MAM_REAL_ROOT") or os.environ.get("WORKSPACE_ROOT") or Path.cwd()).resolve()
self.mam_dir = self.workspace_root / ".mam"
self.checkpoint_file = self.mam_dir / "loop-checkpoint.json"
# Issue #21: the jobs dir honours MAM_JOBS_DIR (and the wrapper is told
# the same dir explicitly). The source root (--worktree) is where the
# diff, base and head come from; everything else - wrapper, registry,
# checkpoint, loop guard, promotion, cleanup - stays on the control
# root (workspace_root).
self.jobs_dir = Path(os.environ.get("MAM_JOBS_DIR") or self.mam_dir / "jobs")
self.worktree_root: Path = self.workspace_root
self.worktree_given = False
self.base_ref: Optional[str] = None
self.base_sha = ""
self.base_source = "head"
self.review_only = False
self.created_jobs: List[str] = []
self.failed_submit_jobs: Set[str] = set()
self.current_plan = ""
@@ -93,6 +106,9 @@ class LoopRunner:
" --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)",
" --verbose Print detailed execution timeline traces",
" --cleanup Purge temporary job directories upon success",
" --worktree <path> Source worktree for diff/base/head (control plane stays here)",
" --base <ref> Pin the review diff base (must be an ancestor of the source HEAD)",
" --review-only Skip implementation; review <base>..HEAD (requires --base)",
]
print("\n".join(lines))
@@ -161,6 +177,22 @@ class LoopRunner:
elif arg == "--cleanup":
self.cleanup = True
i += 1
elif arg == "--worktree":
if i + 1 >= len(args):
self.print_usage()
sys.exit(1)
self.worktree_root = Path(args[i + 1])
self.worktree_given = True
i += 2
elif arg == "--base":
if i + 1 >= len(args):
self.print_usage()
sys.exit(1)
self.base_ref = args[i + 1]
i += 2
elif arg == "--review-only":
self.review_only = True
i += 1
else:
print(f"Unknown option: {arg}")
self.print_usage()
@@ -183,6 +215,67 @@ class LoopRunner:
if self.plan_talk_turns > 0 and not self.plan_mode:
log_warn("--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored.")
# Issue #21 (D3): CLI --worktree wins over MAM_LOOP_WORKTREE. --base has
# no env fallback (D17): a stale sha left in a shell would still pass
# the ancestor check and silently widen the next loop's review range.
if not self.worktree_given and os.environ.get("MAM_LOOP_WORKTREE", "").strip():
self.worktree_root = Path(os.environ["MAM_LOOP_WORKTREE"].strip())
self.worktree_given = True
if self.worktree_given:
self.worktree_root = self._validate_worktree(self.worktree_root)
if self.review_only and self.base_ref is None:
print("ERROR: --review-only requires --base <ref>.", file=sys.stderr)
sys.exit(1)
if self.review_only and self.plan_mode:
print("ERROR: --review-only cannot be combined with --plan.", file=sys.stderr)
sys.exit(1)
if self.base_ref is not None:
self.base_sha = self._resolve_base(self.base_ref)
self.base_source = "cli"
@staticmethod
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
def _git_common_dir(self, cwd: Path) -> str:
res = self._git(cwd, "rev-parse", "--git-common-dir")
if res.returncode != 0 or not res.stdout.strip():
return ""
d = res.stdout.strip()
# the main checkout answers ".git", relative to cwd (not to this process)
return os.path.realpath(d if os.path.isabs(d) else os.path.join(str(cwd), d))
def _validate_worktree(self, p: Path) -> Path:
p = Path(os.path.abspath(str(p)))
if not p.is_dir():
log_error(f"--worktree {p}: no such directory.")
sys.exit(1)
top = self._git(p, "rev-parse", "--show-toplevel")
if top.returncode != 0 or not top.stdout.strip():
log_error(f"--worktree {p}: not a git working tree.")
sys.exit(1)
if os.path.realpath(top.stdout.strip()) != os.path.realpath(p):
log_error(f"--worktree {p}: not the top of its working tree ({top.stdout.strip()}).")
sys.exit(1)
mine, ctl = self._git_common_dir(p), self._git_common_dir(self.workspace_root)
if not mine or mine != ctl:
log_error(f"--worktree {p}: not a worktree of the repository at {self.workspace_root}.")
sys.exit(1)
return Path(os.path.realpath(p))
def _resolve_base(self, ref: str) -> str:
"""--base (D14): a full sha that is an ancestor of the source HEAD."""
res = self._git(self.worktree_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
sha = res.stdout.strip() if res.returncode == 0 else ""
if not sha:
log_error(f"--base {ref}: not a commit in {self.worktree_root}.")
sys.exit(1)
if self._git(self.worktree_root, "merge-base", "--is-ancestor", sha, "HEAD").returncode != 0:
log_error(f"--base {ref} ({sha[:12]}) is not an ancestor of HEAD in {self.worktree_root}; "
"refusing to review an unrelated range.")
sys.exit(1)
return sha
def load_sessions_state(self) -> Dict[str, Any]:
"""Load registered sessions from MAM_STATE_JSON or agent-sessions.db/yaml."""
raw_env = os.environ.get("MAM_STATE_JSON")
@@ -296,6 +389,7 @@ class LoopRunner:
"--agent", agent_type,
"--type", "direct",
"--role", role,
"--registry-dir", str(self.jobs_dir),
"--prompt", p_text,
] + self.contract_args()
env = dict(os.environ)
@@ -346,7 +440,7 @@ class LoopRunner:
if self.verbose:
log_info(f"Monitoring job '{job_id}' for status changes (timeout: {max_wait}s)...")
job_file = self.mam_dir / "jobs" / f"{job_id}.json"
job_file = self.jobs_dir / f"{job_id}.json"
start_time = time.time()
missing_count = 0
unknown_count = 0
@@ -388,7 +482,7 @@ class LoopRunner:
def find_report(self, job_id: str) -> Optional[Path]:
"""Locate report-final.md for a job."""
job_dir = self.mam_dir / "jobs" / job_id
job_dir = self.jobs_dir / job_id
matches = list(job_dir.glob("*/report-final.md")) + list(job_dir.glob("report-final.md"))
if matches:
return matches[0]
@@ -396,20 +490,94 @@ class LoopRunner:
def collect_changes_diff(self) -> str:
"""Collect git diff since base commit."""
# The collector is the control root's copy (never the worktree's
# unreviewed one); the tree it diffs is the source root (Issue #21).
diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
src = str(self.worktree_root)
if diff_script.exists():
cmd = f"source '{diff_script}' && mam_collect_changes_diff '{self.workspace_root}' '{self.base_commit}'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(self.workspace_root))
cmd = (f"source {shlex.quote(str(diff_script))} && "
f"mam_collect_changes_diff {shlex.quote(src)} {shlex.quote(self.base_commit)}")
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src)
if res.returncode == 0:
return res.stdout
# Fallback to direct git diff
try:
cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root))
res = subprocess.run(cmd, capture_output=True, text=True, cwd=src)
return res.stdout
except Exception:
return ""
def _source_head(self) -> str:
res = self._git(self.worktree_root, "rev-parse", "HEAD")
out = res.stdout if isinstance(res.stdout, str) else ""
return out.strip() if res.returncode == 0 else ""
def _context_active(self) -> bool:
return self.worktree_given or self.base_ref is not None
def _worktree_context(self) -> str:
"""The source-root block put in front of every delegated prompt (D6);
empty without --worktree/--base, so prompts stay byte-identical."""
if not self._context_active():
return ""
src = self.worktree_root
branch = self._git(src, "branch", "--show-current").stdout.strip() or "(detached)"
head = self._source_head()
base = self.base_commit or self.base_sha
origin = "--base" if self.base_source == "cli" else "루프 시작 시 HEAD"
count = "?"
if base and head:
r = self._git(src, "rev-list", "--count", f"{base}..HEAD")
if r.returncode == 0 and r.stdout.strip():
count = r.stdout.strip()
st = self._git(src, "status", "--porcelain")
dirty = "알 수 없음" if st.returncode != 0 else ("있음" if st.stdout.strip() else "없음")
return ("[워크트리 컨텍스트]\n"
f"- 소스 경로: {src}\n"
f"- 브랜치: {branch}\n"
f"- base: {base or '(Phase 2 시작 시 HEAD 로 정해짐)'} (출처: {origin})\n"
f"- head: {head} (base..head 커밋: {count}개, 미커밋 변경: {dirty})\n"
f"- 제어 평면(잡 브리프·리포트·레지스트리): {self.workspace_root}\n"
"이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n")
def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str) -> Optional[str]:
"""delegate_job with the source-root context block in front of the prompt."""
return self.delegate_job(agent_session, agent_type, role, self._worktree_context() + prompt)
def _change_set_state(self) -> str:
"""'empty' | 'present' | 'unknown' for the source root against base,
judged by git exit codes only (D16)."""
if not self.base_commit:
return "unknown"
try:
d = self._git(self.worktree_root, "diff", "--quiet", self.base_commit)
if d.returncode == 1:
return "present"
if d.returncode != 0:
return "unknown"
u = self._git(self.worktree_root, "ls-files", "-o", "--exclude-standard", "-z")
if u.returncode != 0:
return "unknown"
return "present" if u.stdout else "empty"
except Exception:
return "unknown"
def _change_set_allows_review(self) -> bool:
"""Fail-closed guard (D16), on only with --worktree/--base: never ask
for a review of an empty or undeterminable change set."""
if not self._context_active():
return True
state = self._change_set_state()
if state == "empty":
log_error(f"empty change set since base {self.base_commit}; refusing to request reviews")
return False
if state != "present":
log_error(f"cannot determine the change set since base {self.base_commit or '(none)'} in "
f"{self.worktree_root}; refusing to request reviews on no evidence")
return False
return True
def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
data = {
"iteration": iteration,
@@ -418,6 +586,10 @@ class LoopRunner:
"plan_job_id": self.plan_job_id,
"review_status": review_status or {},
"base_commit": self.base_commit,
"base_source": self.base_source,
"review_only": self.review_only,
"worktree_root": str(self.worktree_root),
"head_commit": self._source_head() if self._context_active() else "",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
checkpoint.save_checkpoint(self.checkpoint_file, data)
@@ -506,7 +678,7 @@ class LoopRunner:
log_info("Requesting initial implementation plan from Planner...")
p_type = self.resolve_agent_type(planner_session, state)
plan_prompt = f"태스크 목표를 바탕으로 구체적인 구현 계획서를 작성해주세요. 목표: {self.task}"
p_jid = self.delegate_job(planner_session, p_type, "Planner", plan_prompt)
p_jid = self._dispatch(planner_session, p_type, "Planner", plan_prompt)
if not p_jid:
log_error("Failed to register planner job.")
return 1
@@ -528,7 +700,7 @@ class LoopRunner:
for turn in range(1, self.plan_talk_turns + 1):
log_info(f"Discussion Turn {turn}/{self.plan_talk_turns}: Creator challenging the plan...")
c_prompt = f"Planner가 제시한 다음 계획서를 꼼꼼히 검토하고, 실제 구현 시 마주할 수 있는 맹점이나 제약사항 1가지를 발굴하여 Planner에게 이의를 제기(Challenge)해주세요. 계획서:\n{self.current_plan}"
c_jid = self.delegate_job(self.creator, creator_type, "Worker", c_prompt)
c_jid = self._dispatch(self.creator, creator_type, "Worker", c_prompt)
if not c_jid or not self.wait_for_job(c_jid):
log_error("Creator critique step failed.")
return 1
@@ -538,7 +710,7 @@ class LoopRunner:
log_info("Planner refining plan with Creator's feedback...")
refine_prompt = f"작업자(Creator)로부터 다음 이의제기 피드백을 받았습니다. 피드백을 반영하여 계획서를 정교하게 업데이트(Refine)하여 다시 출력해주세요. 피드백:\n{critique_text}\n기존 계획서:\n{self.current_plan}"
ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt)
ref_jid = self._dispatch(planner_session, p_type, "Planner", refine_prompt)
if not ref_jid or not self.wait_for_job(ref_jid):
log_error("Planner refinement step failed.")
return 1
@@ -560,28 +732,34 @@ class LoopRunner:
# PHASE 2: IMPLEMENTATION (CREATION)
# =======================================================================
log_info("=== Phase 2: Code Implementation ===")
try:
res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.workspace_root))
self.base_commit = res_base.stdout.strip()
except Exception:
self.base_commit = ""
if self.current_plan:
exec_prompt = f"이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n{self.current_plan}\n\n작업 목표: {self.task}"
if self.base_sha:
self.base_commit = self.base_sha # --base (D14)
else:
exec_prompt = f"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"
try:
res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.worktree_root))
self.base_commit = res_base.stdout.strip()
except Exception:
self.base_commit = ""
creator_type = self.resolve_agent_type(self.creator, state)
exec_jid = self.delegate_job(self.creator, creator_type, "Worker", exec_prompt)
if not exec_jid:
log_error("Failed to register Creator execution job.")
return 1
log_info(f"Creator Job ID: {exec_jid}")
if self.review_only:
log_info(f"Phase 2 skipped (--review-only): reviewing {self.base_commit}..{self._source_head()}")
else:
if self.current_plan:
exec_prompt = f"이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n{self.current_plan}\n\n작업 목표: {self.task}"
else:
exec_prompt = f"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"
if not self.wait_for_job(exec_jid):
log_error("Creator execution failed.")
return 1
log_success("Initial implementation finished.")
exec_jid = self._dispatch(self.creator, creator_type, "Worker", exec_prompt)
if not exec_jid:
log_error("Failed to register Creator execution job.")
return 1
log_info(f"Creator Job ID: {exec_jid}")
if not self.wait_for_job(exec_jid):
log_error("Creator execution failed.")
return 1
log_success("Initial implementation finished.")
# =======================================================================
# PHASE 3: VERIFICATION LOOP (PEER REVIEW)
@@ -605,12 +783,14 @@ class LoopRunner:
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
rebut_budget = self.max_rebut
self._record_checkpoint(iteration=loop_count)
if not self._change_set_allows_review():
return 1
if not reviewers:
# Creator Self-Review
log_warn("No reviewers specified. Conducting Creator Self-Review...")
sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요."
sr_jid = self.delegate_job(self.creator, creator_type, "Reviewer", sr_prompt)
sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt)
if not sr_jid:
log_error("Failed to register Self-Review job.")
return 1
@@ -634,7 +814,7 @@ class LoopRunner:
log_info(f"Requesting code review from Reviewer '{rev}'...")
r_type = self.resolve_agent_type(rev, state)
r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}"
r_jid = self.delegate_job(rev, r_type, "Reviewer", r_prompt)
r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt)
if not r_jid:
log_error(f"Failed to register review job for '{rev}'.")
return 1
@@ -685,7 +865,7 @@ class LoopRunner:
log_warn("Feedback involves complex code modifications. Diverting to Planner to revise plan...")
p_type = self.resolve_agent_type(planner_session, state)
refine_prompt = f"리뷰어들로부터 다음과 같이 정교한 코드 수정 피드백이 도착했습니다. 해당 피드백을 수렴하여 구현 계획서(Plan)를 갱신(Refine)하여 다시 작성해주세요. 피드백:\n{feedback_aggregate}\n기존 계획서:\n{self.current_plan}"
ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt)
ref_jid = self._dispatch(planner_session, p_type, "Planner", refine_prompt)
if not ref_jid or not self.wait_for_job(ref_jid):
log_error("Failed to register plan refinement job.")
return 1
@@ -700,7 +880,7 @@ class LoopRunner:
if self.max_rebut > 0:
correction_prompt += f"\n\n[이의제기 채널] 위 피드백 중 타당하지 않다고 판단되는 항목이 있다면, 그 항목은 구현하지 말고 반론을 제기하십시오. 반론 시 리포트에 단독 행으로 '[REBUT: <리뷰어_세션명>]' 태그를 남기고, 해당 리뷰어의 지적 중 어느 항목을 왜 거부하는지 근거를 함께 서술하십시오. 타당한 지적은 정상적으로 반영하십시오. 반론할 항목이 없으면 태그를 남기지 마십시오. 대상 리뷰어 세션명: {' '.join(fail_revs) if fail_revs else '(없음)'}"
correct_jid = self.delegate_job(self.creator, creator_type, "Worker", correction_prompt)
correct_jid = self._dispatch(self.creator, creator_type, "Worker", correction_prompt)
if not correct_jid or not self.wait_for_job(correct_jid):
log_error("Failed to register Creator correction job.")
return 1
@@ -728,7 +908,7 @@ class LoopRunner:
log_info(f"Re-adjudication: returning rebuttal to Reviewer '{rt}'...")
rt_type = self.resolve_agent_type(rt, state)
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
adj_jid = self.delegate_job(rt, rt_type, "Reviewer", adj_prompt)
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt)
if not adj_jid or not self.wait_for_job(adj_jid):
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
overruled_revs.append(rt)
@@ -747,7 +927,7 @@ class LoopRunner:
log_info(f"Deadlock on {' '.join(overruled_revs)}; escalating to Planner for arbitration...")
p_type = self.resolve_agent_type(planner_session, state)
arb_prompt = f"작업자(Creator)와 리뷰어의 의견이 충돌하여 교착 상태입니다. 계획서 관점에서 어느 쪽이 타당한지 재정(arbitration)하십시오. 리포트 마지막에 단독 행으로 '[ARBITRATION: CREATOR]' 또는 '[ARBITRATION: REVIEWER]' 를 명시하십시오. 리뷰어 지적:\n{feedback_aggregate}\n작업자 반론:\n{rebuttal_text}\n기존 계획서:\n{self.current_plan}"
arb_jid = self.delegate_job(planner_session, p_type, "Planner", arb_prompt)
arb_jid = self._dispatch(planner_session, p_type, "Planner", arb_prompt)
if arb_jid and self.wait_for_job(arb_jid):
arb_file = self.find_report(arb_jid)
if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"):
@@ -761,7 +941,7 @@ class LoopRunner:
log_warn("Ruling stands with the reviewer(s); Creator must comply.")
rebut_budget = 0
comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}"
comp_jid = self.delegate_job(self.creator, creator_type, "Worker", comply_prompt)
comp_jid = self._dispatch(self.creator, creator_type, "Worker", comply_prompt)
if comp_jid:
self.wait_for_job(comp_jid)
@@ -788,10 +968,10 @@ class LoopRunner:
if self.cleanup:
log_info("Cleaning up temporary job directories created during this loop run...")
for job in self.created_jobs:
job_dir = self.mam_dir / "jobs" / job
job_dir = self.jobs_dir / job
if job_dir.exists():
shutil.rmtree(job_dir, ignore_errors=True)
sub_out = self.mam_dir / "jobs" / f"{job}.subscriber.out"
sub_out = self.jobs_dir / f"{job}.subscriber.out"
if sub_out.exists():
sub_out.unlink(missing_ok=True)
if self.verbose: